* Re-apply token support

* Updated nonce headers

* Updated package-lock.json

* test commit to debug failing git hooks

* Revert "test commit to debug failing git hooks"

This reverts commit e64086b0a7aede154705be09c7b3433b08bc1e34.

* JsonWebToken utility class for generating and validating HS256 JWT tokens. Removed third-party JWT library.

* Add ext-hash to composer (required by hash_hmac())

* Removed unnecessary method param.

* Tests for retrieving cart contents via Cart-Token

* Removed token tests ( we can't properly test cart token functionality until we refactor the way it intercepts calls to replace the session object )

* Abstracted payload from JsonWebToken class. We can now use it to encode custom payloads and reuse them wherever we want.

* Fixed missing check for token expiration in the payload.

* MD lint error and config fix

* Update composer.lock

* Fixed bug using the wrong nonce header.

* Refactor to properly save session data based on cart token.

* Refactored DB queries to properly use prepared statement

* Removed underscore prefix for class attributes

* Fixed spaces instead of tabs indenting composer.json. Cleaned up .editorconfig

* Cleaned up borked .md comments.

* Comment for WP_SETUP_CONFIG check.

* Reverted SQL prepared statement for including table names.

* Used hash_equals() for signature comparison. Renamed some wrongly named properties.

* Updated composer.lock

* Reverted some accidentally removed lines on some documentation files.

* Reverted accidentally removed line on docs/internal-developers/testing/releases/404.md

* Changed param type from mixed to

Co-authored-by: Paulo Arromba <17236129+wavvves@users.noreply.github.com>
Co-authored-by: Seghir Nadir <nadir.seghir@gmail.com>
This commit is contained in:
Mike Jolley 2022-10-03 11:49:32 +01:00 committed by GitHub
parent 33910f316f
commit 6f93c5cf1b
8 changed files with 476 additions and 50 deletions

View File

@ -18,10 +18,10 @@ trim_trailing_whitespace = true
[*.txt]
trim_trailing_whitespace = false
[*.{json,yml}]
[*.yml]
trim_trailing_whitespace = false
indent_style = space
indent_size = 2
[*.json]
indent_style = tab
[*.md]
indent_style = space

View File

@ -12,6 +12,8 @@
"prefer-stable": true,
"minimum-stability": "dev",
"require": {
"ext-hash": "*",
"ext-json": "*",
"composer/installers": "^1.7.0",
"automattic/jetpack-autoloader": "^2.9.1"
},

View File

@ -4,20 +4,20 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "938acec166de3572a0884e7e81a9ba52",
"content-hash": "d441b0288f8e32e9445bc3887462211c",
"packages": [
{
"name": "automattic/jetpack-autoloader",
"version": "v2.11.7",
"version": "v2.11.9",
"source": {
"type": "git",
"url": "https://github.com/Automattic/jetpack-autoloader.git",
"reference": "65170ab358aa5a8efd9de96666a46b74dc74513d"
"reference": "966247ebaf42f4c9076144f0eee0e3ea90fdd4c9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Automattic/jetpack-autoloader/zipball/65170ab358aa5a8efd9de96666a46b74dc74513d",
"reference": "65170ab358aa5a8efd9de96666a46b74dc74513d",
"url": "https://api.github.com/repos/Automattic/jetpack-autoloader/zipball/966247ebaf42f4c9076144f0eee0e3ea90fdd4c9",
"reference": "966247ebaf42f4c9076144f0eee0e3ea90fdd4c9",
"shasum": ""
},
"require": {
@ -53,9 +53,9 @@
],
"description": "Creates a custom autoloader for a plugin or theme.",
"support": {
"source": "https://github.com/Automattic/jetpack-autoloader/tree/v2.11.7"
"source": "https://github.com/Automattic/jetpack-autoloader/tree/v2.11.9"
},
"time": "2022-07-26T13:41:25+00:00"
"time": "2022-09-27T17:31:10+00:00"
},
{
"name": "composer/installers",
@ -1837,16 +1837,16 @@
},
{
"name": "sebastian/comparator",
"version": "4.0.6",
"version": "4.0.8",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/comparator.git",
"reference": "55f4261989e546dc112258c7a75935a81a7ce382"
"reference": "fa0f136dd2334583309d32b62544682ee972b51a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/55f4261989e546dc112258c7a75935a81a7ce382",
"reference": "55f4261989e546dc112258c7a75935a81a7ce382",
"url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/fa0f136dd2334583309d32b62544682ee972b51a",
"reference": "fa0f136dd2334583309d32b62544682ee972b51a",
"shasum": ""
},
"require": {
@ -1899,7 +1899,7 @@
],
"support": {
"issues": "https://github.com/sebastianbergmann/comparator/issues",
"source": "https://github.com/sebastianbergmann/comparator/tree/4.0.6"
"source": "https://github.com/sebastianbergmann/comparator/tree/4.0.8"
},
"funding": [
{
@ -1907,7 +1907,7 @@
"type": "github"
}
],
"time": "2020-10-26T15:49:45+00:00"
"time": "2022-09-14T12:41:17+00:00"
},
{
"name": "sebastian/diff",
@ -2040,16 +2040,16 @@
},
{
"name": "sebastian/exporter",
"version": "4.0.4",
"version": "4.0.5",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/exporter.git",
"reference": "65e8b7db476c5dd267e65eea9cab77584d3cfff9"
"reference": "ac230ed27f0f98f597c8a2b6eb7ac563af5e5b9d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/65e8b7db476c5dd267e65eea9cab77584d3cfff9",
"reference": "65e8b7db476c5dd267e65eea9cab77584d3cfff9",
"url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/ac230ed27f0f98f597c8a2b6eb7ac563af5e5b9d",
"reference": "ac230ed27f0f98f597c8a2b6eb7ac563af5e5b9d",
"shasum": ""
},
"require": {
@ -2105,7 +2105,7 @@
],
"support": {
"issues": "https://github.com/sebastianbergmann/exporter/issues",
"source": "https://github.com/sebastianbergmann/exporter/tree/4.0.4"
"source": "https://github.com/sebastianbergmann/exporter/tree/4.0.5"
},
"funding": [
{
@ -2113,7 +2113,7 @@
"type": "github"
}
],
"time": "2021-11-11T14:18:36+00:00"
"time": "2022-09-14T06:03:37+00:00"
},
{
"name": "sebastian/global-state",
@ -2514,16 +2514,16 @@
},
{
"name": "squizlabs/php_codesniffer",
"version": "3.6.1",
"version": "3.7.1",
"source": {
"type": "git",
"url": "https://github.com/squizlabs/PHP_CodeSniffer.git",
"reference": "f268ca40d54617c6e06757f83f699775c9b3ff2e"
"reference": "1359e176e9307e906dc3d890bcc9603ff6d90619"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/f268ca40d54617c6e06757f83f699775c9b3ff2e",
"reference": "f268ca40d54617c6e06757f83f699775c9b3ff2e",
"url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/1359e176e9307e906dc3d890bcc9603ff6d90619",
"reference": "1359e176e9307e906dc3d890bcc9603ff6d90619",
"shasum": ""
},
"require": {
@ -2566,7 +2566,7 @@
"source": "https://github.com/squizlabs/PHP_CodeSniffer",
"wiki": "https://github.com/squizlabs/PHP_CodeSniffer/wiki"
},
"time": "2021-10-11T04:00:11+00:00"
"time": "2022-06-18T07:21:10+00:00"
},
{
"name": "theseer/tokenizer",
@ -2828,7 +2828,10 @@
"stability-flags": [],
"prefer-stable": true,
"prefer-lowest": false,
"platform": [],
"platform": {
"ext-hash": "*",
"ext-json": "*"
},
"platform-dev": [],
"platform-overrides": {
"php": "7.4.24"

View File

@ -1,14 +1,18 @@
<?php
namespace Automattic\WooCommerce\StoreApi\Routes\V1;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
use Automattic\WooCommerce\StoreApi\SchemaController;
use Automattic\WooCommerce\StoreApi\Schemas\V1\AbstractSchema;
use Automattic\WooCommerce\StoreApi\Schemas\V1\CartSchema;
use Automattic\WooCommerce\StoreApi\Schemas\V1\CartItemSchema;
use Automattic\WooCommerce\StoreApi\Schemas\V1\CartSchema;
use Automattic\WooCommerce\StoreApi\SessionHandler;
use Automattic\WooCommerce\StoreApi\Utilities\CartController;
use Automattic\WooCommerce\StoreApi\Utilities\DraftOrderTrait;
use Automattic\WooCommerce\StoreApi\Utilities\JsonWebToken;
use Automattic\WooCommerce\StoreApi\Utilities\OrderController;
/**
* Abstract Cart Route
*/
@ -16,7 +20,7 @@ abstract class AbstractCartRoute extends AbstractRoute {
use DraftOrderTrait;
/**
* The routes schema.
* The route's schema.
*
* @var string
*/
@ -29,6 +33,13 @@ abstract class AbstractCartRoute extends AbstractRoute {
*/
protected $cart_schema;
/**
* Schema class for the cart item.
*
* @var CartItemSchema
*/
protected $cart_item_schema;
/**
* Cart controller class instance.
*
@ -50,29 +61,30 @@ abstract class AbstractCartRoute extends AbstractRoute {
* @param AbstractSchema $schema Schema class for this route.
*/
public function __construct( SchemaController $schema_controller, AbstractSchema $schema ) {
$this->schema_controller = $schema_controller;
$this->schema = $schema;
$this->cart_schema = $this->schema_controller->get( CartSchema::IDENTIFIER );
$this->cart_item_schema = $this->schema_controller->get( CartItemSchema::IDENTIFIER );
$this->cart_controller = new CartController();
$this->order_controller = new OrderController();
parent::__construct( $schema_controller, $schema );
$this->cart_schema = $this->schema_controller->get( CartSchema::IDENTIFIER );
$this->cart_item_schema = $this->schema_controller->get( CartItemSchema::IDENTIFIER );
$this->cart_controller = new CartController();
$this->order_controller = new OrderController();
}
/**
* Get the route response based on the type of request.
*
* @param \WP_REST_Request $request Request object.
* @return \WP_Error|\WP_REST_Response
*
* @return \WP_REST_Response
*/
public function get_response( \WP_REST_Request $request ) {
$this->cart_controller->load_cart();
$this->load_cart_session( $request );
$this->calculate_totals();
if ( $this->requires_nonce( $request ) ) {
$nonce_check = $this->check_nonce( $request );
if ( is_wp_error( $nonce_check ) ) {
return $this->add_nonce_headers( $this->error_to_response( $nonce_check ) );
return $this->add_response_headers( $this->error_to_response( $nonce_check ) );
}
}
@ -81,7 +93,7 @@ abstract class AbstractCartRoute extends AbstractRoute {
} catch ( RouteException $error ) {
$response = $this->get_route_error_response( $error->getErrorCode(), $error->getMessage(), $error->getCode(), $error->getAdditionalData() );
} catch ( \Exception $error ) {
$response = $this->get_route_error_response( 'woocommerce_rest_unknown_server_error', $error->getMessage(), 500 );
$response = $this->get_route_error_response( 'woocommerce_rest_unknown_server_error', $error->getMessage() );
}
if ( is_wp_error( $response ) ) {
@ -90,21 +102,23 @@ abstract class AbstractCartRoute extends AbstractRoute {
$this->cart_updated( $request );
}
return $this->add_nonce_headers( $response );
return $this->add_response_headers( $response );
}
/**
* Add nonce headers to a response object.
*
* @param \WP_REST_Response $response The response object.
*
* @return \WP_REST_Response
*/
protected function add_nonce_headers( \WP_REST_Response $response ) {
protected function add_response_headers( \WP_REST_Response $response ) {
$nonce = wp_create_nonce( 'wc_store_api' );
$response->header( 'Nonce', $nonce );
$response->header( 'Nonce-Timestamp', time() );
$response->header( 'User-ID', get_current_user_id() );
$response->header( 'Cart-Token', $this->get_cart_token() );
// The following headers are deprecated and should be removed in a future version.
$response->header( 'X-WC-Store-API-Nonce', $nonce );
@ -112,10 +126,69 @@ abstract class AbstractCartRoute extends AbstractRoute {
return $response;
}
/**
* Load the cart session before handling responses.
*
* @param \WP_REST_Request $request Request object.
*/
protected function load_cart_session( \WP_REST_Request $request ) {
$cart_token = $request->get_header( 'Cart-Token' );
if ( $cart_token && JsonWebToken::validate( $cart_token, $this->get_cart_token_secret() ) ) {
// Overrides the core session class.
add_filter(
'woocommerce_session_handler',
function () {
return SessionHandler::class;
}
);
}
$this->cart_controller->load_cart();
}
/**
* Generates a cart token for the response headers.
*
* Current namespace is used as the token Issuer.
* *
*
* @return string
*/
protected function get_cart_token() {
return JsonWebToken::create(
[
'user_id' => wc()->session->get_customer_id(),
'exp' => $this->get_cart_token_expiration(),
'iss' => $this->namespace,
],
$this->get_cart_token_secret()
);
}
/**
* Gets the secret for the cart token using wp_salt.
*
* @return string
*/
protected function get_cart_token_secret() {
return '@' . wp_salt();
}
/**
* Gets the expiration of the cart token. Defaults to 48h.
*
* @return int
*/
protected function get_cart_token_expiration() {
return time() + intval( apply_filters( 'wc_session_expiration', 60 * 60 * 48 ) );
}
/**
* Checks if a nonce is required for the route.
*
* @param \WP_REST_Request $request Request.
*
* @return bool
*/
protected function requires_nonce( \WP_REST_Request $request ) {
@ -173,6 +246,7 @@ abstract class AbstractCartRoute extends AbstractRoute {
* to match the logged in cookie in your browser.
*
* @param \WP_REST_Request $request Request object.
*
* @return \WP_Error|boolean
*/
protected function check_nonce( \WP_REST_Request $request ) {
@ -194,6 +268,7 @@ abstract class AbstractCartRoute extends AbstractRoute {
* This can be used to disable the nonce check when testing API endpoints via a REST API client.
*
* @param boolean $disable_nonce_check If true, nonce checks will be disabled.
*
* @return boolean
*/
if ( apply_filters( 'woocommerce_store_api_disable_nonce_check', false ) ) {
@ -217,7 +292,8 @@ abstract class AbstractCartRoute extends AbstractRoute {
* @param string $error_code String based error code.
* @param string $error_message User facing error message.
* @param int $http_status_code HTTP status. Defaults to 500.
* @param array $additional_data Extra data (key value pairs) to expose in the error response.
* @param array $additional_data Extra data (key value pairs) to expose in the error response.
*
* @return \WP_Error WP Error object.
*/
protected function get_route_error_response( $error_code, $error_message, $http_status_code = 500, $additional_data = [] ) {
@ -238,6 +314,7 @@ abstract class AbstractCartRoute extends AbstractRoute {
)
);
}
return new \WP_Error( $error_code, $error_message, [ 'status' => $http_status_code ] );
}
}

View File

@ -0,0 +1,113 @@
<?php
namespace Automattic\WooCommerce\StoreApi;
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\StoreApi\Utilities\JsonWebToken;
use WC_Session;
defined( 'ABSPATH' ) || exit;
/**
* SessionHandler class
*/
final class SessionHandler extends WC_Session {
/**
* Token from HTTP headers.
*
* @var string
*/
protected $token;
/**
* Table name for session data.
*
* @var string Custom session table name
*/
protected $table;
/**
* Expiration timestamp.
*
* @var int
*/
protected $session_expiration;
/**
* Constructor for the session class.
*/
public function __construct() {
$this->token = wc_clean( wp_unslash( $_SERVER['HTTP_CART_TOKEN'] ?? '' ) );
$this->table = $GLOBALS['wpdb']->prefix . 'woocommerce_sessions';
}
/**
* Init hooks and session data.
*/
public function init() {
$this->init_session_from_token();
add_action( 'shutdown', array( $this, 'save_data' ), 20 );
}
/**
* Process the token header to load the correct session.
*/
protected function init_session_from_token() {
$payload = JsonWebToken::get_parts( $this->token )->payload;
$this->_customer_id = $payload->user_id;
$this->session_expiration = $payload->exp;
$this->_data = (array) $this->get_session( $this->_customer_id, array() );
}
/**
* Returns the session.
*
* @param string $customer_id Customer ID.
* @param mixed $default Default session value.
*
* @return string|array|bool
*/
public function get_session( $customer_id, $default = false ) {
global $wpdb;
// This mimics behaviour from default WC_Session_Handler class. There will be no sessions retrieved while WP setup is due.
if ( Constants::is_defined( 'WP_SETUP_CONFIG' ) ) {
return false;
}
$value = $wpdb->get_var(
$wpdb->prepare(
"SELECT session_value FROM $this->table WHERE session_key = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$customer_id
)
);
if ( is_null( $value ) ) {
$value = $default;
}
return maybe_unserialize( $value );
}
/**
* Save data and delete user session.
*/
public function save_data() {
// Dirty if something changed - prevents saving nothing new.
if ( $this->_dirty ) {
global $wpdb;
$wpdb->query(
$wpdb->prepare(
"INSERT INTO $this->table (`session_key`, `session_value`, `session_expiry`) VALUES (%s, %s, %d) ON DUPLICATE KEY UPDATE `session_value` = VALUES(`session_value`), `session_expiry` = VALUES(`session_expiry`)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
$this->_customer_id,
maybe_serialize( $this->_data ),
$this->session_expiration
)
);
$this->_dirty = false;
}
}
}

View File

@ -0,0 +1,196 @@
<?php
namespace Automattic\WooCommerce\StoreApi\Utilities;
/**
* JsonWebToken class.
*
* Simple Json Web Token generator & verifier static utility class, currently supporting only HS256 signatures.
*/
final class JsonWebToken {
/**
* JWT header type.
*
* @var string
*/
private static $type = 'JWT';
/**
* JWT algorithm to generate signature.
*
* @var string
*/
private static $algorithm = 'HS256';
/**
* Generates a token from provided data and secret.
*
* @param array $payload Payload data.
* @param string $secret The secret used to generate the signature.
*
* @return string
*/
public static function create( array $payload, string $secret ) {
$header = self::to_base_64_url( self::generate_header() );
$payload = self::to_base_64_url( self::generate_payload( $payload ) );
$signature = self::to_base_64_url( self::generate_signature( $header . '.' . $payload, $secret ) );
return $header . '.' . $payload . '.' . $signature;
}
/**
* Validates a provided token against the provided secret.
* Checks for format, valid header for our class, expiration claim validity and signature.
* https://datatracker.ietf.org/doc/html/rfc7519#section-7.2
*
* @param string $token Full token string.
* @param string $secret The secret used to generate the signature.
*
* @return bool
*/
public static function validate( string $token, string $secret ) {
/**
* Confirm the structure of a JSON Web Token, it has three parts separated
* by dots and complies with Base64URL standards.
*/
if ( preg_match( '/^[a-zA-Z\d\-_=]+\.[a-zA-Z\d\-_=]+\.[a-zA-Z\d\-_=]+$/', $token ) !== 1 ) {
return false;
}
$parts = self::get_parts( $token );
/**
* Check if header declares a supported JWT by this class.
*/
if (
! is_object( $parts->header ) ||
! property_exists( $parts->header, 'typ' ) ||
! property_exists( $parts->header, 'alg' ) ||
self::$type !== $parts->header->typ ||
self::$algorithm !== $parts->header->alg
) {
return false;
}
/**
* Check if token is expired.
*/
if ( ! property_exists( $parts->payload, 'exp' ) || time() > (int) $parts->payload->exp ) {
return false;
}
/**
* Check if the token is based on our secret.
*/
$encoded_regenerated_signature = self::to_base_64_url(
self::generate_signature( $parts->header_encoded . '.' . $parts->payload_encoded, $secret )
);
return hash_equals( $encoded_regenerated_signature, $parts->signature_encoded );
}
/**
* Returns the decoded/encoded header, payload and signature from a token string.
*
* @param string $token Full token string.
*
* @return object
*/
public static function get_parts( string $token ) {
$parts = explode( '.', $token );
return (object) array(
'header' => json_decode( self::from_base_64_url( $parts[0] ) ),
'header_encoded' => $parts[0],
'payload' => json_decode( self::from_base_64_url( $parts[1] ) ),
'payload_encoded' => $parts[1],
'signature' => self::from_base_64_url( $parts[2] ),
'signature_encoded' => $parts[2],
);
}
/**
* Generates the json formatted header for our HS256 JWT token.
*
* @return string|bool
*/
private static function generate_header() {
return wp_json_encode(
array(
'alg' => self::$algorithm,
'typ' => self::$type,
)
);
}
/**
* Generates a sha256 signature for the provided string using the provided secret.
*
* @param string $string Header + Payload token substring.
* @param string $secret The secret used to generate the signature.
*
* @return false|string
*/
private static function generate_signature( string $string, string $secret ) {
return hash_hmac(
'sha256',
$string,
$secret,
true
);
}
/**
* Generates the payload in json formatted string.
*
* @param array $payload Payload data.
*
* @return string|bool
*/
private static function generate_payload( array $payload ) {
return wp_json_encode( array_merge( $payload, [ 'iat' => time() ] ) );
}
/**
* Encodes a string to url safe base64.
*
* @param string $string The string to be encoded.
*
* @return string
*/
private static function to_base_64_url( string $string ) {
return str_replace(
array( '+', '/', '=' ),
array( '-', '_', '' ),
base64_encode( $string ) // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
);
}
/**
* Decodes a string encoded using url safe base64, supporting auto padding.
*
* @param string $string the string to be decoded.
*
* @return string
*/
private static function from_base_64_url( string $string ) {
/**
* Add padding to base64 strings which require it. Some base64 URL strings
* which are decoded will have missing padding which is represented by the
* equals sign.
*/
if ( strlen( $string ) % 4 !== 0 ) {
return self::from_base_64_url( $string . '=' );
}
return base64_decode( // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
str_replace(
array( '-', '_' ),
array( '+', '/' ),
$string
)
);
}
}

View File

@ -7,6 +7,9 @@ namespace Automattic\WooCommerce\Blocks\Tests\StoreApi\Routes;
use Automattic\WooCommerce\Blocks\Tests\Helpers\FixtureData;
use Automattic\WooCommerce\Blocks\Tests\Helpers\ValidateSchema;
use Automattic\WooCommerce\StoreApi\SessionHandler;
use Automattic\WooCommerce\StoreApi\Utilities\JsonWebToken;
use Spy_REST_Server;
/**
* Cart Controller Tests.
@ -87,7 +90,7 @@ class Cart extends ControllerTestCase {
'needs_payment' => true,
'needs_shipping' => true,
'items_weight' => '30',
'items' => function( $value ) {
'items' => function ( $value ) {
return count( $value ) === 2;
},
'cross_sells' => array(
@ -155,7 +158,7 @@ class Cart extends ControllerTestCase {
200,
array(
'items_count' => 1,
'items' => function( $value ) {
'items' => function ( $value ) {
return count( $value ) === 1;
},
'items_weight' => '10',
@ -221,7 +224,13 @@ class Cart extends ControllerTestCase {
$action_callback = \Mockery::mock( 'ActionCallback' );
$action_callback->shouldReceive( 'do_customer_callback' )->once();
add_action( 'woocommerce_store_api_cart_update_customer_from_request', array( $action_callback, 'do_customer_callback' ) );
add_action(
'woocommerce_store_api_cart_update_customer_from_request',
array(
$action_callback,
'do_customer_callback',
)
);
$this->assertAPIResponse(
$request,
@ -242,7 +251,13 @@ class Cart extends ControllerTestCase {
)
);
remove_action( 'woocommerce_store_api_cart_update_customer_from_request', array( $action_callback, 'do_customer_callback' ) );
remove_action(
'woocommerce_store_api_cart_update_customer_from_request',
array(
$action_callback,
'do_customer_callback',
)
);
}
/**
@ -509,4 +524,24 @@ class Cart extends ControllerTestCase {
$diff = $validate->get_diff_from_object( $response->get_data() );
$this->assertEmpty( $diff );
}
/**
* Tests for Cart-Token header presence and validity.
*/
public function test_cart_token_header() {
/** @var Spy_REST_Server $server */
$server = rest_get_server();
$server->serve_request( '/wc/store/cart' );
$this->assertArrayHasKey( 'Cart-Token', $server->sent_headers );
$this->assertTrue(
JsonWebToken::validate(
$server->sent_headers['Cart-Token'],
'@' . wp_salt()
)
);
}
}

View File

@ -95,9 +95,9 @@ abstract class ControllerTestCase extends \WP_Test_REST_TestCase {
/**
* Custom assertion of an API response to confirm data and response code is expected.
*
* @param string $endpoint_or_request Route endpoint to get.
* @param int $expected_response_code Expected response code.
* @param array $expected_response_data Expected response data.
* @param string|\WP_Rest_Request $endpoint_or_request Route endpoint to get.
* @param int $expected_response_code Expected response code.
* @param array $expected_response_data Expected response data.
*/
public function assertApiResponse( $endpoint_or_request, $expected_response_code, $expected_response_data = null ) {
$response = is_a( $endpoint_or_request, '\WP_Rest_Request' ) ? rest_get_server()->dispatch( $endpoint_or_request ) : $this->getApiResponse( $endpoint_or_request );