From e3657ad2ad30b054c6b3405e390ba67181c85355 Mon Sep 17 00:00:00 2001 From: Matthew Batchelder Date: Sat, 17 Aug 2024 10:53:54 -0400 Subject: [PATCH] Do wp_unslash() before deep sanitization. This necessitates migrating the tests to Codeception so we don't need to do weird things for wp_unslash(). The bulk of this changeset is actually the test migration to Codeception... :) --- .env.testing | 57 ++++ .env.testing.slic | 57 ++++ .github/workflows/tests-php.yml | 111 ++++++-- codeception.dist.yml | 14 + codeception.slic.yml | 3 + composer.json | 2 +- src/SuperGlobals/SuperGlobals.php | 12 +- tests/{bootstrap.php => _bootstrap.php} | 0 tests/_support/.gitignore | 1 + tests/_support/Helper/Container.php | 70 +++++ .../Helper/Http_API/Http_API_Mock.php | 258 ++++++++++++++++++ .../Helper/Licensing/Service_Mock.php | 61 +++++ tests/_support/Helper/Muwpunit.php | 10 + tests/_support/Helper/Sample_Plugin.php | 5 + tests/_support/Helper/Traits/With_Uopz.php | 107 ++++++++ tests/_support/Helper/UplinkTestCase.php | 41 +++ tests/_support/MuwpunitTester.php | 26 ++ tests/_support/WpunitTester.php | 26 ++ tests/wpunit.suite.dist.yml | 31 +++ tests/{Unit => wpunit}/SuperGlobalsTest.php | 2 +- tests/wpunit/_bootstrap.php | 1 + 21 files changed, 870 insertions(+), 25 deletions(-) create mode 100644 .env.testing create mode 100644 .env.testing.slic create mode 100644 codeception.dist.yml create mode 100644 codeception.slic.yml rename tests/{bootstrap.php => _bootstrap.php} (100%) create mode 100644 tests/_support/.gitignore create mode 100644 tests/_support/Helper/Container.php create mode 100644 tests/_support/Helper/Http_API/Http_API_Mock.php create mode 100644 tests/_support/Helper/Licensing/Service_Mock.php create mode 100644 tests/_support/Helper/Muwpunit.php create mode 100644 tests/_support/Helper/Sample_Plugin.php create mode 100644 tests/_support/Helper/Traits/With_Uopz.php create mode 100644 tests/_support/Helper/UplinkTestCase.php create mode 100644 tests/_support/MuwpunitTester.php create mode 100644 tests/_support/WpunitTester.php create mode 100644 tests/wpunit.suite.dist.yml rename tests/{Unit => wpunit}/SuperGlobalsTest.php (99%) create mode 100644 tests/wpunit/_bootstrap.php diff --git a/.env.testing b/.env.testing new file mode 100644 index 0000000..ac84e89 --- /dev/null +++ b/.env.testing @@ -0,0 +1,57 @@ +# This file will be consumed by both the CI and the tests. +# Some environment variables might not apply to one but might apply to the other: modify with care. + +# What version of WordPress we want to install and test against. +# This has to be compatible with the `wp core download` command, see https://developer.wordpress.org/cli/commands/core/download/. +WP_VERSION=latest + +# This is where, in the context of the CI, we'll install and configure WordPress. +# See `.travis.yml` for more information. +WP_ROOT_FOLDER=/tmp/wordpress + +# The WordPress installation will be served from the Docker container. +# See `dev/docker/ci-compose.yml` for more information. +WP_URL=http://localhost:8080 +WP_DOMAIN=localhost:8080 + +# The credentials that will be used to access the site in acceptance tests +# in methods like `$I->loginAsAdmin();`. +WP_ADMIN_USERNAME=admin +WP_ADMIN_PASSWORD=password + +WP_DB_PORT=4306 + +# The databse is served from the Docker `db` container. +# See `dev/docker/ci-compose.yml` for more information. +WP_TABLE_PREFIX=wp_ +WP_DB_HOST=127.0.0.1:4306 +WP_DB_NAME=wordpress +WP_DB_USER=root +WP_DB_PASSWORD= + +# The test databse is served from the Docker `db` container. +# See `dev/docker/ci-compose.yml` for more information. +WP_TEST_DB_HOST=127.0.0.1:4306 +WP_TEST_DB_NAME=test +WP_TEST_DB_USER=root +WP_TEST_DB_PASSWORD= + +# We're using Selenium and Chrome for acceptance testing. +# In CI context we're starting a Docker container to handle that. +# See the `dev/docker/ci-compose.yml` file. +CHROMEDRIVER_HOST=localhost +CHROMEDRIVER_PORT=4444 + +# The URL of the WordPress installation from the point of view of the Chromedriver container. +# Why not just use `wordpress`? While Chrome will accept an `http://wordpress` address WordPress +# will not, we call the WordPress container with a seemingly looking legit URL and leverage the +# lines that, in the `wp-config.php` file, will make it so that WordPress will use as its home +# URL whatever URL we reach it with. +# See the `dev/docker/wp-config.php` template for more information. +WP_CHROMEDRIVER_URL="wp.test" + +# To run the tests let's force the background-processing lib to run in synchronous (single PHP thread) mode. +TRIBE_NO_ASYNC=1 + +# We're using Docker to run the tests. +USING_CONTAINERS=1 diff --git a/.env.testing.slic b/.env.testing.slic new file mode 100644 index 0000000..20de04b --- /dev/null +++ b/.env.testing.slic @@ -0,0 +1,57 @@ +# This file will be consumed by both the CI and the tests. +# Some environment variables might not apply to one but might apply to the other: modify with care. + +# What version of WordPress we want to install and test against. +# This has to be compatible with the `wp core download` command, see https://developer.wordpress.org/cli/commands/core/download/. +WP_VERSION=latest + +# This is where, in the context of the CI, we'll install and configure WordPress. +# See `.travis.yml` for more information. +WP_ROOT_FOLDER=/var/www/html + +# The WordPress installation will be served from the Docker container. +# See `dev/docker/ci-compose.yml` for more information. +WP_URL=http://wordpress.test +WP_DOMAIN=wordpress.test + +# The credentials that will be used to access the site in acceptance tests +# in methods like `$I->loginAsAdmin();`. +WP_ADMIN_USERNAME=admin +WP_ADMIN_PASSWORD=password + +WP_DB_PORT=3306 + +# The databse is served from the Docker `db` container. +# See `dev/docker/ci-compose.yml` for more information. +WP_TABLE_PREFIX=wp_ +WP_DB_HOST=db +WP_DB_NAME=test +WP_DB_USER=root +WP_DB_PASSWORD=password + +# The test databse is served from the Docker `db` container. +# See `dev/docker/ci-compose.yml` for more information. +WP_TEST_DB_HOST=db +WP_TEST_DB_NAME=test +WP_TEST_DB_USER=root +WP_TEST_DB_PASSWORD=password + +# We're using Selenium and Chrome for acceptance testing. +# In CI context we're starting a Docker container to handle that. +# See the `dev/docker/ci-compose.yml` file. +CHROMEDRIVER_HOST=chrome +CHROMEDRIVER_PORT=4444 + +# The URL of the WordPress installation from the point of view of the Chromedriver container. +# Why not just use `wordpress`? While Chrome will accept an `http://wordpress` address WordPress +# will not, we call the WordPress container with a seemingly looking legit URL and leverage the +# lines that, in the `wp-config.php` file, will make it so that WordPress will use as its home +# URL whatever URL we reach it with. +# See the `dev/docker/wp-config.php` template for more information. +WP_CHROMEDRIVER_URL=http://wordpress.test + +# To run the tests let's force the background-processing lib to run in synchronous (single PHP thread) mode. +TRIBE_NO_ASYNC=1 + +# We're using Docker to run the tests. +USING_CONTAINERS=1 diff --git a/.github/workflows/tests-php.yml b/.github/workflows/tests-php.yml index 2a8d3d6..87df058 100644 --- a/.github/workflows/tests-php.yml +++ b/.github/workflows/tests-php.yml @@ -1,25 +1,102 @@ -name: Tests +name: 'CI' on: push: jobs: - tests: - name: tests + test: runs-on: ubuntu-latest + strategy: + matrix: + # Update this as WordPress releases new backward patches: https://wordpress.org/download/releases/ + # Currently supporting two releases back. + wordpress: + - latest + + php: + - '7.4' + + name: "Tests: WP ${{ matrix.wordpress }} / PHP ${{ matrix.php }}" steps: - - name: Checkout - uses: actions/checkout@v2 - - name: Configure PHP environment - uses: shivammathur/setup-php@v2 + - name: Checkout the repository + uses: actions/checkout@v4 with: - php-version: '7.4' - extensions: mbstring, intl - coverage: none - - uses: ramsey/composer-install@v2 + fetch-depth: 1000 + submodules: recursive + # ------------------------------------------------------------------------------ + # Checkout slic + # ------------------------------------------------------------------------------ + - name: Checkout slic + uses: actions/checkout@v4 with: - composer-options: "--ignore-platform-reqs --optimize-autoloader" - - name: Setup git + repository: stellarwp/slic + ref: main + path: slic + fetch-depth: 1 + # ------------------------------------------------------------------------------ + # Prepare our composer cache directory + # ------------------------------------------------------------------------------ + - name: Get Composer Cache Directory + id: get-composer-cache-dir run: | - git config --global user.name "GitHub Actions" - git config --global user.email "<>" - - name: Run tests - run: php vendor/bin/phpunit --bootstrap=tests/bootstrap.php --no-coverage + echo "DIR=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - uses: actions/cache@v4 + id: composer-cache + with: + path: ${{ steps.get-composer-cache-dir.outputs.DIR }} + key: ${{ matrix.php }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: | + ${{ matrix.php }}-composer- + + # ------------------------------------------------------------------------------ + # Initialize slic + # ------------------------------------------------------------------------------ + - name: Set up slic env vars + run: | + echo "SLIC_BIN=${GITHUB_WORKSPACE}/slic/slic" >> $GITHUB_ENV + echo "SLIC_WP_DIR=${GITHUB_WORKSPACE}/slic/_wordpress" >> $GITHUB_ENV + echo "SLIC_WORDPRESS_DOCKERFILE=Dockerfile.base" >> $GITHUB_ENV + + - name: Set run context for slic + run: echo "SLIC=1" >> $GITHUB_ENV && echo "CI=1" >> $GITHUB_ENV + + - name: Start ssh-agent + run: | + mkdir -p "${HOME}/.ssh"; + ssh-agent -a /tmp/ssh_agent.sock; + + - name: Export SSH_AUTH_SOCK env var + run: echo "SSH_AUTH_SOCK=/tmp/ssh_agent.sock" >> $GITHUB_ENV + + - name: Set up slic for CI + run: | + cd ${GITHUB_WORKSPACE}/.. + ${SLIC_BIN} here + ${SLIC_BIN} interactive off + ${SLIC_BIN} build-prompt off + ${SLIC_BIN} build-subdir off + ${SLIC_BIN} xdebug off + ${SLIC_BIN} debug on + ${SLIC_BIN} php-version set ${{ matrix.php }} --skip-rebuild + ${SLIC_BIN} composer-cache set ${{ steps.get-composer-cache-dir.outputs.DIR }} + ${SLIC_BIN} info + ${SLIC_BIN} config + + - name: Install specific WordPress version ${{ matrix.wordpress }} + run: | + ${SLIC_BIN} wp core download --version=${{ matrix.wordpress }} --force + + - name: Show WordPress version + run: ${SLIC_BIN} wp core version + + - name: Update installed WordPress themes + run: ${SLIC_BIN} wp theme update --all + + - name: Set up StellarWP SuperGlobals + run: | + ${SLIC_BIN} use superglobals + ${SLIC_BIN} composer set-version 2 + ${SLIC_BIN} composer validate + ${SLIC_BIN} composer install + + - name: Run wpunit tests + run: ${SLIC_BIN} run wpunit --ext DotReporter diff --git a/codeception.dist.yml b/codeception.dist.yml new file mode 100644 index 0000000..dc50b32 --- /dev/null +++ b/codeception.dist.yml @@ -0,0 +1,14 @@ +actor: Tester +bootstrap: _bootstrap.php +paths: + tests: tests + log: tests/_output + data: tests/_data + helpers: tests/_support + wp_root: "%WP_ROOT_FOLDER%" +settings: + colors: true + memory_limit: 1024M +params: + # read dynamic configuration parameters from the .env file + - .env.testing diff --git a/codeception.slic.yml b/codeception.slic.yml new file mode 100644 index 0000000..9bcf605 --- /dev/null +++ b/codeception.slic.yml @@ -0,0 +1,3 @@ +params: + # read dynamic configuration parameters from the .env file + - .env.testing.slic diff --git a/composer.json b/composer.json index dc738e1..c33c1a6 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,7 @@ "stellarwp/arrays": "^1.2" }, "require-dev": { - "phpunit/phpunit": "<10.0", + "lucatume/wp-browser": "^3.0.14", "szepeviktor/phpstan-wordpress": "^1.1", "symfony/event-dispatcher-contracts": "^2.5.1", "symfony/string": "^5.4" diff --git a/src/SuperGlobals/SuperGlobals.php b/src/SuperGlobals/SuperGlobals.php index ca9d485..794d7e4 100644 --- a/src/SuperGlobals/SuperGlobals.php +++ b/src/SuperGlobals/SuperGlobals.php @@ -29,7 +29,7 @@ public static function get_server_var( $var, $default = null ) { return $default; } - $unsafe = Arr::get_in_any( $data, $var, $default ); + $unsafe = wp_unslash( Arr::get_in_any( $data, $var, $default ) ); return static::sanitize_deep( $unsafe ); } @@ -46,7 +46,7 @@ public static function get_server_var( $var, $default = null ) { * @return mixed */ public static function get_get_var( string $var, $default = null ) { - $unsafe = Arr::get( (array) $_GET, $var, $default ); + $unsafe = wp_unslash( Arr::get( (array) $_GET, $var, $default ) ); return static::sanitize_deep( $unsafe ); } @@ -63,7 +63,7 @@ public static function get_get_var( string $var, $default = null ) { * @return mixed */ public static function get_post_var( string $var, $default = null ) { - $unsafe = Arr::get( (array) $_POST, $var, $default ); + $unsafe = wp_unslash( Arr::get( (array) $_POST, $var, $default ) ); return static::sanitize_deep( $unsafe ); } @@ -80,7 +80,7 @@ public static function get_post_var( string $var, $default = null ) { * @return mixed */ public static function get_env_var( string $var, $default = null ) { - $unsafe = Arr::get( (array) $_ENV, $var, $default ); + $unsafe = wp_unslash( Arr::get( (array) $_ENV, $var, $default ) ); return static::sanitize_deep( $unsafe ); } @@ -134,7 +134,7 @@ public static function get_raw_superglobal( string $superglobal ) { * @return mixed */ public static function get_sanitized_superglobal( string $superglobal ) { - $var = static::get_raw_superglobal( $superglobal ); + $var = wp_unslash( static::get_raw_superglobal( $superglobal ) ); return static::sanitize_deep( $var ); } @@ -176,7 +176,7 @@ public static function get_var( $var, $default = null ) { return $default; } - $unsafe = Arr::get_in_any( $requests, $var, $default ); + $unsafe = wp_unslash( Arr::get_in_any( $requests, $var, $default ) ); return static::sanitize_deep( $unsafe ); } diff --git a/tests/bootstrap.php b/tests/_bootstrap.php similarity index 100% rename from tests/bootstrap.php rename to tests/_bootstrap.php diff --git a/tests/_support/.gitignore b/tests/_support/.gitignore new file mode 100644 index 0000000..36e264c --- /dev/null +++ b/tests/_support/.gitignore @@ -0,0 +1 @@ +_generated diff --git a/tests/_support/Helper/Container.php b/tests/_support/Helper/Container.php new file mode 100644 index 0000000..de3d937 --- /dev/null +++ b/tests/_support/Helper/Container.php @@ -0,0 +1,70 @@ +container = $container ?: new DI52Container(); + } + + /** + * @inheritDoc + */ + public function bind( string $id, $implementation = null, array $afterBuildMethods = null ) { + $this->container->bind( $id, $implementation, $afterBuildMethods ); + } + + /** + * @inheritDoc + */ + public function get( string $id ) { + return $this->container->get( $id ); + } + + /** + * @return DI52Container + */ + public function get_container() { + return $this->container; + } + + /** + * @inheritDoc + */ + public function has( string $id ) { + return $this->container->has( $id ); + } + + /** + * @inheritDoc + */ + public function singleton( string $id, $implementation = null, array $afterBuildMethods = null ) { + $this->container->singleton( $id, $implementation, $afterBuildMethods ); + } + + /** + * Defer all other calls to the container object. + */ + public function __call( $name, $args ) { + return $this->container->{$name}( ...$args ); + } + + public function make($id) + { + return $this->get($id); + } + +} diff --git a/tests/_support/Helper/Http_API/Http_API_Mock.php b/tests/_support/Helper/Http_API/Http_API_Mock.php new file mode 100644 index 0000000..cb945a6 --- /dev/null +++ b/tests/_support/Helper/Http_API/Http_API_Mock.php @@ -0,0 +1,258 @@ + 'Continue', + 101 => 'Switching Protocols', + 102 => 'Processing', + 103 => 'Early Hints', + 200 => 'OK', + 201 => 'Created', + 202 => 'Accepted', + 203 => 'Non-Authoritative Information', + 204 => 'No Content', + 205 => 'Reset Content', + 206 => 'Partial Content', + 207 => 'Multi-Status', + 208 => 'Already Reported', + 226 => 'IM Used', + 300 => 'Multiple Choices', + 301 => 'Moved Permanently', + 302 => 'Found', + 303 => 'See Other', + 304 => 'Not Modified', + 305 => 'Use Proxy', + 307 => 'Temporary Redirect', + 308 => 'Permanent Redirect', + 400 => 'Bad Request', + 401 => 'Unauthorized', + 402 => 'Payment Required', + 403 => 'Forbidden', + 404 => 'Not Found', + 405 => 'Method Not Allowed', + 406 => 'Not Acceptable', + 407 => 'Proxy Authentication Required', + 408 => 'Request Timeout', + 409 => 'Conflict', + 410 => 'Gone', + 411 => 'Length Required', + 412 => 'Precondition Failed', + 413 => 'Content Too Large', + 414 => 'URI Too Long', + 415 => 'Unsupported Media Type', + 416 => 'Range Not Satisfiable', + 417 => 'Expectation Failed', + 421 => 'Misdirected Request', + 422 => 'Unprocessable Content', + 423 => 'Locked', + 424 => 'Failed Dependency', + 425 => 'Too Early', + 426 => 'Upgrade Required', + 428 => 'Precondition Required', + 429 => 'Too Many Requests', + 431 => 'Request Header Fields Too Large', + 451 => 'Unavailable For Legal Reasons', + 500 => 'Internal Server Error', + 501 => 'Not Implemented', + 502 => 'Bad Gateway', + 503 => 'Service Unavailable', + 504 => 'Gateway Timeout', + 505 => 'HTTP Version Not Supported', + 506 => 'Variant Also Negotiates', + 507 => 'Insufficient Storage', + 508 => 'Loop Detected', + 510 => 'Not Extended', + 511 => 'Network Authentication Required' + ]; + + /** + * A map from method and URI to the response to return. + * + * @var array> + */ + protected $mock_responses = []; + + /** + * Build and return a mock response. + * + * @param int $status_code The response status code. + * @param string|array $body The response body. + * @param string $content_type The response Content Type; e.g., 'application/json' or 'text/html'. + * + * @return array The mock response, in the same format returned by the `wp_remote_request()` function. + * + * @throws \JsonException If the response body cannot be encoded as JSON. + */ + public function make_response( int $status_code, $body, string $content_type = 'applicaton/json' ): array { + if ( $content_type === 'application/json' ) { + $body = is_string( $body ) ? $body : json_encode( $body, JSON_THROW_ON_ERROR ); + } elseif ( $content_type === 'application/x-www-form-urlencoded' ) { + $body = is_string( $body ) ? $body : http_build_query( $body ); + } + + $url = rtrim( $this->get_url(), '/' ); + $current_date = ( new \DateTime( 'now', new \DateTimezone( 'GMT' ) ) )->format( 'D, d M Y H:i:s GMT' ); + $request_response = new Requests_Response(); + $request_response->headers = new \Requests_Response_Headers( [ + 'date' => [ $current_date ], + 'content-type' => [ "$content_type; charset=UTF-8" ], + 'server' => [ 'nginx' ], + 'vary' => [ 'Accept-Encoding' ], + 'x-robots-tag' => [ 'noindex' ], + 'link' => [ '<' . $url . '>; rel="https://api.w.org/"' ], + 'x-content-type-options' => [ 'nosniff' ], + 'access-control-expose-headers' => [ 'X-WP-Total, X-WP-TotalPages, Link' ], + 'access-control-allow-headers' => [ 'Authorization, X-WP-Nonce, Content-Disposition, Content-MD5, Content-Type' ], + 'allow' => [ 'GET, POST' ], + 'strict-transport-security' => [ 'max-age=31536000; includeSubdomains; preload;' ], + 'cache-control' => [ 'store, must-revalidate, post-check=0, pre-check=0' ], + 'access-control-allow-origin' => [ '*' ], + 'x-frame-options' => [ 'SAMEORIGIN' ], + 'x-xss-protection' => [ '1; mode=block' ], + 'alternate-protocol' => [ '443:npn-spdy/3' ], + 'x-ua-compatible' => [ 'IE=Edge' ], + 'content-encoding' => [ 'gzip' ], + ] ); + $request_response->cookies = new \Requests_Cookie_Jar( [] ); + $status_message = self::$status_messages[ $status_code ] ?? 'Unknown'; + foreach ( + [ + 'body' => $body, + 'raw' => "HTTP/1.1 $status_code $status_message +Date: Wed, 05 Oct 2022 09:30:09 GMT +Content-Type: $content_type; charset=UTF-8 +Transfer-Encoding: chunked +Connection: close +Server: nginx +Vary: Accept-Encoding +X-Robots-Tag: noindex +Link: <$url>; rel=\"https://api.w.org/\" +X-Content-Type-Options: nosniff +Access-Control-Expose-Headers: X-WP-Total, X-WP-TotalPages, Link +Access-Control-Allow-Headers: Authorization, X-WP-Nonce, Content-Disposition, Content-MD5, Content-Type +Allow: GET, POST +Strict-Transport-Security: max-age=31536000; includeSubdomains; preload; +Cache-Control: store, must-revalidate, post-check=0, pre-check=0 +Access-Control-Allow-Origin: * +X-Frame-Options: SAMEORIGIN +X-Content-Type-Options: nosniff +X-XSS-Protection: 1; mode=block +Alternate-Protocol: 443:npn-spdy/3 +X-UA-Compatible: IE=Edge +Content-Encoding: gzip + +$body", + 'status_code' => $status_code, + 'protocol_version' => 1.1, + 'success' => $status_code < 400, + 'redirects' => 0, + 'url' => 'https://pue.theeventscalendar.com/api/plugins/v2/license/validate', + 'history' => [], + ] as $key => $value + ) { + $request_response->{$key} = $value; + } + $http_response = new \WP_HTTP_Requests_Response( $request_response ); + + return [ + 'headers' => + new \Requests_Utility_CaseInsensitiveDictionary( + [ + 'date' => $current_date, + 'content-type' => 'application/json; charset=UTF-8', + 'server' => 'nginx', + 'vary' => 'Accept-Encoding', + 'x-robots-tag' => 'noindex', + 'link' => '; rel="https://api.w.org/"', + 'x-content-type-options' => + [ + 0 => 'nosniff', + 1 => 'nosniff', + ], + 'access-control-expose-headers' => 'X-WP-Total, X-WP-TotalPages, Link', + 'access-control-allow-headers' => 'Authorization, X-WP-Nonce, Content-Disposition, Content-MD5, Content-Type', + 'allow' => 'GET, POST', + 'strict-transport-security' => 'max-age=31536000; includeSubdomains; preload;', + 'cache-control' => 'store, must-revalidate, post-check=0, pre-check=0', + 'access-control-allow-origin' => '*', + 'x-frame-options' => 'SAMEORIGIN', + 'x-xss-protection' => '1; mode=block', + 'alternate-protocol' => '443:npn-spdy/3', + 'x-ua-compatible' => 'IE=Edge', + 'content-encoding' => 'gzip', + ] + ), + 'body' => $body, + 'response' => [ 'code' => $status_code, 'message' => $status_message, ], + 'cookies' => [], + 'filename' => null, + 'http_response' => $http_response, + ]; + } + + /** + * Sets up the HPTT API mock to return a response to a specific request. + * + * @param string $method The HTTP method to mock, defaults to `GET`. + * @param string $uri The URI to mock, relative to the URL specified by the extending class `get_url` + * method. + * @param array|callable $response Either a response in array format, or a callable that will return a + * response and will receive the request parsed arguments and URL as input. + * + * @return void The corresponding mock will be set up. + */ + public function will_reply_to_request( string $method, string $uri, $response ): void { + $method = strtoupper( $method ); + $uri = '/' . ltrim( $uri, '/' ); + $key = "$method $uri"; + $this->mock_responses[ $key ] = $response; + if ( ! has_filter( 'pre_http_request', [ $this, 'mock_http_response' ] ) ) { + add_filter( 'pre_http_request', [ $this, 'mock_http_response' ], 10, 3 ); + } + } + + /** + * Hooked on the `pre_http_request` filter to prefill the mocked HTTP responses. + * + * @param bool $preempt Whether to preempt an HTTP request's return value. Default `false`. + * @param array $parsed_args The HTTP request arguments. + * @param string $url The full request URL. + * + * @return false|mixed Either the mocked response or `false` to let the request go through. + */ + public function mock_http_response( bool $preempt, array $parsed_args, string $url ) { + $uri = '/' . ltrim( str_replace( $this->get_url(), '', $url ), '/' ); + $method = $parsed_args['method'] ?? 'GET'; + $key = "$method $uri"; + + if ( ! isset( $this->mock_responses[ $key ] ) ) { + // We do not have a mock for this, let the HTTP API run its course. + return false; + } + + $mock_response = $this->mock_responses[ $key ]; + + if ( is_callable( $mock_response ) ) { + $mock_response = $mock_response( $parsed_args, $url ); + } + + return $mock_response; + } + + /** + * Returns the root URL to use for the mocked HTTP API requests. + * + * @return string The root URL to use for the mocked HTTP API requests. + */ + abstract protected function get_url(): string; + +} diff --git a/tests/_support/Helper/Licensing/Service_Mock.php b/tests/_support/Helper/Licensing/Service_Mock.php new file mode 100644 index 0000000..de62698 --- /dev/null +++ b/tests/_support/Helper/Licensing/Service_Mock.php @@ -0,0 +1,61 @@ + The body of a success response to the key validation request. + */ + public function get_validate_key_success_body(): array { + return [ + 'results' => [ + [ + 'name' => 'Test Plugin', + 'slug' => 'test-plugin', + 'zip_url' => '', + 'file_prefix' => 'test-plugin.6.0.1', + 'homepage' => 'http://evnt.is/test-plugin', + 'download_url' => 'https://pue.theeventscalendar.com/api/plugins/v2/download?plugin=test-plugin&version=6.0.1', + 'icon_svg_url' => 'https://pue.theeventscalendar.com/product-images/test-plugin.svg', + 'version' => '6.0.1', + 'requires' => '5.8.4', + 'tested' => '6.0.2', + 'release_date' => '2022-09-22 00:00:00', + 'upgrade_notice' => 'Remember to always make a backup of your database and files before updating!', + 'last_updated' => '2022-09-22 17:52:39', + 'sections' => + [ + 'description' => 'A test plugin', + 'installation' => 'Installation instructions.', + 'changelog' => '

Changelog notes

', + ], + 'expiration' => '2028-05-12', + 'daily_limit' => null, + 'custom_update' => + [ + 'icons' => + [ + 'svg' => 'https://pue.theeventscalendar.com/product-icons/test-plugin.svg', + ], + ], + 'api_upgrade' => false, + 'api_expired' => false, + 'api_message' => null, + ], + ], + ]; + } + + /** + * {@inheritdoc } + */ + protected function get_url(): string { + return 'https://licensing.stellarwp.com/api/'; + } + +} diff --git a/tests/_support/Helper/Muwpunit.php b/tests/_support/Helper/Muwpunit.php new file mode 100644 index 0000000..7d1fe55 --- /dev/null +++ b/tests/_support/Helper/Muwpunit.php @@ -0,0 +1,10 @@ +uopz_set_returns as $f ) { + if ( is_array( $f ) ) { + list( $class, $method ) = $f; + uopz_unset_return( $class, $method ); + } else { + uopz_unset_return( $f ); + } + } + } + + if ( function_exists( 'uopz_redefine' ) ) { + foreach ( $this->uopz_redefines as $restore_callback ) { $restore_callback(); + } + } + } + + /** + * Wrapper for uopz_set_return + * + * @since 4.15.1 + * + * @param [type] $fn The name of an existing function + * @param [type] $value The value the function should return. + * If a Closure is provided and the execute flag is set, + * the Closure will be executed in place of the original function. + * @param boolean $execute If true, and a Closure was provided as the value, + * the Closure will be executed in place of the original function. + * @return void + */ + private function set_fn_return( $fn, $value, $execute = false ) { + if ( ! function_exists( 'uopz_set_return' ) ) { + $this->markTestSkipped( 'uopz extension is not installed' ); + } + uopz_set_return( $fn, $value, $execute ); + $this->uopz_set_returns[] = $fn; + } + + private function set_const_value( $const, ...$args ) { + if ( ! function_exists( 'uopz_redefine' ) ) { + $this->markTestSkipped( 'uopz extension is not installed' ); + } + + if ( count( $args ) === 1 ) { + // Normal const redefinition. + $previous_value = defined( $const ) ? constant( $const ) : null; + if ( null === $previous_value ) { + $restore_callback = static function () use ( $const ) { + uopz_undefine( $const ); + Assert::assertFalse( defined( $const ) ); + }; + } else { + $restore_callback = static function () use ( $previous_value, $const ) { + uopz_redefine( $const, $previous_value ); + Assert::assertEquals( $previous_value, constant( $const ) ); + }; + } + uopz_redefine( $const, $args[0] ); + $this->uopz_redefines[] = $restore_callback; + + return; + } + + // Static class const redefinition. + $class = $const; + list( $const, $value ) = $args; + $previous_value = defined( $class . '::' . $const ) ? + constant( $class . '::' . $const ) + : null; + + if ( null === $previous_value ) { + $restore_callback = static function () use ( $const, $class ) { + uopz_undefine( $class, $const ); + Assert::assertFalse( defined( $class . '::' . $const ) ); + }; + } else { + $restore_callback = static function () use ( $class, $const, $previous_value ) { + uopz_redefine( $class, $const, $previous_value ); + Assert::assertEquals( $previous_value, constant( $class . '::' . $const ) ); + }; + } + uopz_redefine( $const, ...$args ); + $this->uopz_redefines[] = $restore_callback; + } + + private function set_class_fn_return( $class, $method, $value, $execute = false ) { + if ( ! function_exists( 'uopz_set_return' ) ) { + $this->markTestSkipped( 'uopz extension is not installed' ); + } + uopz_set_return( $class, $method, $value, $execute ); + $this->uopz_set_returns[] = [ $class, $method ]; + } +} diff --git a/tests/_support/Helper/UplinkTestCase.php b/tests/_support/Helper/UplinkTestCase.php new file mode 100644 index 0000000..61911f0 --- /dev/null +++ b/tests/_support/Helper/UplinkTestCase.php @@ -0,0 +1,41 @@ +container = Config::get_container(); + } + + protected function tearDown(): void { + Config::reset(); + + parent::tearDown(); + } + +} diff --git a/tests/_support/MuwpunitTester.php b/tests/_support/MuwpunitTester.php new file mode 100644 index 0000000..ae50404 --- /dev/null +++ b/tests/_support/MuwpunitTester.php @@ -0,0 +1,26 @@ +assertEquals( $_SERVER['REQUEST_METHOD'], SuperGlobals::get_server_var( 'REQUEST_METHOD', 'default' ) ); $this->assertNotEquals( 'POST', SuperGlobals::get_server_var( 'REQUEST_METHOD', 'default' ) ); - $this->assertEquals( 'default', SuperGlobals::get_server_var( 'REQUEST_URI', 'default' ) ); + $this->assertEquals( 'default', SuperGlobals::get_server_var( 'UNSET_VALUE', 'default' ) ); unset( $_SERVER['REQUEST_METHOD'] ); } diff --git a/tests/wpunit/_bootstrap.php b/tests/wpunit/_bootstrap.php new file mode 100644 index 0000000..b3d9bbc --- /dev/null +++ b/tests/wpunit/_bootstrap.php @@ -0,0 +1 @@ +