Files
Geoff TaylorandGitHub f0c7667aad fix: v1.0.1 bugfixes — session key, cart persistence, null attrs (#1012)
* fix: session secret key, cart session persistence, null variation attributes

- Use wp_salt() as fallback secret key instead of hardcoded 24-byte string
  to satisfy php-jwt v7's HS256 minimum key length requirement (Closes #1009)
- Call get_cart_from_session() after wc_load_cart() in
  initialize_session_and_cart() to prevent cart queries from clearing
  persisted session data (Closes #1010)
- Guard against null $attrs in variation_attributes_to_data_array()
  to prevent PHP warnings when variations have no attributes (Closes #1011)
- Return empty array instead of null for product attribute options
  when no terms exist

* chore: Linter compliances met
2026-04-01 13:17:30 -04:00

770 lines
20 KiB
PHP

<?php
use WPGraphQL\WooCommerce\Vendor\Firebase\JWT\JWT;
use WPGraphQL\WooCommerce\Vendor\Firebase\JWT\Key;
use Tests\WPGraphQL\Logger\CodeceptLogger as Signal;
class QLSessionHandlerCest {
private $product_catalog;
public function _before( FunctionalTester $I ) {
// Create products.
$this->product_catalog = $I->getCatalog();
if ( ! defined( 'GRAPHQL_WOOCOMMERCE_SECRET_KEY' ) ) {
define( 'GRAPHQL_WOOCOMMERCE_SECRET_KEY', 'testestestestestestestestestest!!' );
}
}
// tests
public function testCartMutationsWithValidCartSessionToken( FunctionalTester $I ) {
/**
* Add item to the cart
*/
$success = $I->addToCart(
[
'clientMutationId' => 'someId',
'productId' => $this->product_catalog['t-shirt'],
'quantity' => 5,
]
);
$I->assertQuerySuccessful(
$success,
[
$I->expectField( 'addToCart.cartItem.key', Signal::NOT_NULL ),
]
);
$cart_item_key = $I->lodashGet( $success, 'data.addToCart.cartItem.key' );
/**
* Assert existence and validity of "woocommerce-session" HTTP header.
*/
$I->seeHttpHeaderOnce( 'woocommerce-session' );
$session_token = $I->grabHttpHeader( 'woocommerce-session' );
// Decode token
JWT::$leeway = 60;
$token_data = ! empty( $session_token )
? JWT::decode( $session_token, new Key( GRAPHQL_WOOCOMMERCE_SECRET_KEY, 'HS256' ) )
: null;
$I->assertNotEmpty( $token_data );
$I->assertNotEmpty( $token_data->iss );
$I->assertNotEmpty( $token_data->iat );
$I->assertNotEmpty( $token_data->nbf );
$I->assertNotEmpty( $token_data->exp );
$I->assertNotEmpty( $token_data->data );
$I->assertNotEmpty( $token_data->data->customer_id );
$wp_url = getenv( 'WORDPRESS_URL' );
$I->assertEquals( $token_data->iss, $wp_url );
/**
* Make a cart query request with "woocommerce-session" HTTP Header and confirm
* correct cart contents.
*/
$query = '
query {
cart {
contents {
nodes {
key
}
}
}
}
';
$actual = $I->sendGraphQLRequest( $query, null, [ 'woocommerce-session' => "Session {$session_token}" ] );
$I->assertQuerySuccessful(
$actual,
[
$I->expectField( 'cart.contents.nodes.#.key', $cart_item_key ),
]
);
/**
* Remove item from the cart
*/
$success = $I->removeItemsFromCart(
[
'clientMutationId' => 'someId',
'keys' => $cart_item_key,
],
[ 'woocommerce-session' => "Session {$session_token}" ]
);
$I->assertQuerySuccessful(
$success,
[
$I->expectField( 'removeItemsFromCart.cartItems.#.key', $cart_item_key ),
]
);
/**
* Make a cart query request with "woocommerce-session" HTTP Header and confirm
* correct cart contents.
*/
$query = '
query {
cart {
contents {
nodes {
key
}
}
}
}
';
$actual = $I->sendGraphQLRequest( $query, null, [ 'woocommerce-session' => "Session {$session_token}" ] );
$I->assertQuerySuccessful(
$actual,
[
$I->expectField( 'cart.contents.nodes', Signal::IS_FALSY ),
]
);
/**
* Restore item to the cart
*/
$success = $I->restoreCartItems(
[
'clientMutationId' => 'someId',
'keys' => [ $cart_item_key ],
],
[ 'woocommerce-session' => "Session {$session_token}" ]
);
$I->assertQuerySuccessful(
$success,
[
$I->expectField( 'restoreCartItems.cartItems.#.key', $cart_item_key ),
]
);
/**
* Make a cart query request with "woocommerce-session" HTTP Header and confirm
* correct cart contents.
*/
$query = '
query {
cart {
contents {
nodes {
key
}
}
}
}
';
$actual = $I->sendGraphQLRequest( $query, null, [ 'woocommerce-session' => "Session {$session_token}" ] );
$I->assertQuerySuccessful(
$actual,
[
$I->expectField( 'cart.contents.nodes.#.key', $cart_item_key ),
]
);
}
public function testCartMutationsWithInvalidCartSessionToken( FunctionalTester $I ) {
/**
* Add item to cart and retrieve session token to corrupt.
*/
$success = $I->addToCart(
[
'clientMutationId' => 'someId',
'productId' => $this->product_catalog['t-shirt'],
'quantity' => 1,
]
);
$I->assertQuerySuccessful(
$success,
[
$I->expectField( 'addToCart.cartItem.key', Signal::NOT_NULL ),
]
);
$cart_item_key = $I->lodashGet( $success, 'data.addToCart.cartItem.key' );
/**
* Retrieve session token from "woocommerce-session" HTTP response header.
*/
$I->seeHttpHeaderOnce( 'woocommerce-session' );
$valid_token = $I->grabHttpHeader( 'woocommerce-session' );
// Decode token
$token_data = ! empty( $valid_token )
? JWT::decode( $valid_token, new Key( GRAPHQL_WOOCOMMERCE_SECRET_KEY, 'HS256' ) )
: null;
/**
* Attempt to add item to the cart with invalid session token.
* GraphQL should throw an error and mutation will fail.
*/
$invalid_token = $token_data;
$invalid_token->data->customer_id = '';
$invalid_token = JWT::encode( (array) $invalid_token, GRAPHQL_WOOCOMMERCE_SECRET_KEY, 'HS256' );
$failed = $I->addToCart(
[
'clientMutationId' => 'someId',
'productId' => $this->product_catalog['t-shirt'],
'quantity' => 1,
],
[ 'woocommerce-session' => "Session {$invalid_token}" ]
);
$I->assertQueryError( $failed );
/**
* Attempt to remove item from the cart with invalid session token.
* GraphQL should throw an error and mutation will fail.
*/
$invalid_token = $token_data;
$invalid_token->iss = '';
$invalid_token = JWT::encode( (array) $invalid_token, GRAPHQL_WOOCOMMERCE_SECRET_KEY, 'HS256' );
$failed = $I->removeItemsFromCart(
[
'clientMutationId' => 'someId',
'keys' => $cart_item_key,
],
[ 'woocommerce-session' => "Session {$invalid_token}" ]
);
$I->assertQueryError( $failed );
/**
* Attempt to update quantity of item in the cart with invalid session token.
* GraphQL should throw an error and mutation will fail.
*/
$failed = $I->updateItemQuantities(
[
'clientMutationId' => 'someId',
'items' => [
[
'key' => $cart_item_key,
'quantity' => 0,
],
],
],
[ 'woocommerce-session' => 'Session invalid-jwt-token-string' ]
);
$I->assertQueryError( $failed );
/**
* Attempt to empty cart with invalid session token.
* GraphQL should throw an error and mutation will fail.
*/
$failed = $I->emptyCart(
[ 'clientMutationId' => 'someId' ],
[ 'woocommerce-session' => 'Session invalid-jwt-token-string' ]
);
$I->assertQueryError( $failed );
/**
* Attempt to add fee on cart with invalid session token.
* GraphQL should throw an error and mutation will fail.
*/
$failed = $I->addFee(
[
'clientMutationId' => 'someId',
'name' => 'extra_fee',
'amount' => 49.99,
],
[ 'woocommerce-session' => 'Session invalid-jwt-token-string' ]
);
$I->assertQueryError( $failed );
/**
* Attempt to apply coupon on cart with invalid session token.
* GraphQL should throw an error and mutation will fail.
*
* @Note: No coupons exist in the database, but mutation should fail before that becomes a factor.
*/
$failed = $I->applyCoupon(
[
'clientMutationId' => 'someId',
'code' => 'some_coupon',
],
[ 'woocommerce-session' => 'Session invalid-jwt-token-string' ]
);
$I->assertQueryError( $failed );
/**
* Attempt to remove coupon from cart with invalid session token.
* GraphQL should throw an error and mutation will fail.
*
* @Note: No coupons exist on the cart, but mutation should failed before that becomes a factor.
*/
$failed = $I->removeCoupons(
[
'clientMutationId' => 'someId',
'codes' => [ 'some_coupon' ],
],
[ 'woocommerce-session' => 'Session invalid-jwt-token-string' ]
);
$I->assertQueryError( $failed );
/**
* Attempt to restore item to the cart with invalid session token.
* GraphQL should throw an error and mutation will fail.
*
* @Note: No items have been removed from the cart in this session,
* but mutation should failed before that becomes a factor.
*/
$failed = $I->restoreCartItems(
[
'clientMutationId' => 'someId',
'keys' => [ $cart_item_key ],
],
[ 'woocommerce-session' => 'Session invalid-jwt-token-string' ]
);
$I->assertQueryError( $failed );
/**
* Attempt to restore item to the cart with invalid session token.
* GraphQL should throw an error and mutation will fail.
*
* @Note: No items have been removed from the cart in this session,
* but mutation should failed before that becomes a factor.
*/
$failed = $I->updateShippingMethod(
[
'clientMutationId' => 'someId',
'shippingMethods' => [ 'legacy_flat_rate' ],
],
[ 'woocommerce-session' => 'Session invalid-jwt-token-string' ]
);
$I->assertQueryError( $failed );
/**
* Attempt to query cart with invalid session token.
* GraphQL should throw an error and query will fail.
*/
$query = '
query {
cart {
contents {
nodes {
key
}
}
}
}
';
$failed = $I->sendGraphQLRequest(
$query,
null,
[ 'woocommerce-session' => 'Session invalid-jwt-token-string' ]
);
$I->assertQueryError( $failed );
}
public function testCartSessionDataMutations( FunctionalTester $I, $scenario ) {
//$scenario->skip( 'Test skipped until scenario can be created properly again.' );
/**
* Add item to the cart
*/
$success = $I->addToCart(
[
'clientMutationId' => 'someId',
'productId' => $this->product_catalog['socks'],
'quantity' => 2,
]
);
$I->assertQuerySuccessful(
$success,
[
$I->expectField( 'addToCart.cartItem.key', Signal::NOT_NULL ),
]
);
$cart_item_key = $I->lodashGet( $success, 'data.addToCart.cartItem.key' );
/**
* Assert existence and validity of "woocommerce-session" HTTP header.
*/
$I->seeHttpHeaderOnce( 'woocommerce-session' );
$session_token = $I->grabHttpHeader( 'woocommerce-session' );
// Decode token
JWT::$leeway = 60;
$token_data = ! empty( $session_token )
? JWT::decode( $session_token, new Key( GRAPHQL_WOOCOMMERCE_SECRET_KEY, 'HS256' ) )
: null;
$I->assertNotEmpty( $token_data );
$I->assertNotEmpty( $token_data->iss );
$I->assertNotEmpty( $token_data->iat );
$I->assertNotEmpty( $token_data->nbf );
$I->assertNotEmpty( $token_data->exp );
$I->assertNotEmpty( $token_data->data );
$I->assertNotEmpty( $token_data->data->customer_id );
$wp_url = getenv( 'WORDPRESS_URL' );
$I->assertEquals( $token_data->iss, $wp_url );
/**
* Set shipping address, so shipping rates can be calculated
*/
$input = [
'clientMutationId' => 'someId',
'shipping' => [
'state' => 'New York',
'country' => 'US',
'postcode' => '12345',
],
];
$mutation = '
mutation ( $input: UpdateCustomerInput! ){
updateCustomer ( input: $input ) {
customer {
shipping {
state
country
postcode
}
}
}
}
';
$actual = $I->sendGraphQLRequest( $mutation, [ 'input' => $input ], [ 'woocommerce-session' => "Session {$session_token}" ] );
$I->assertQuerySuccessful(
$actual,
[
$I->expectObject(
'updateCustomer.customer.shipping',
[
$I->expectField( 'state', 'New York' ),
$I->expectField( 'country', 'US' ),
$I->expectField( 'postcode', '12345' ),
]
)
]
);
/**
* Make a cart query request with "woocommerce-session" HTTP Header and confirm
* correct cart contents and chosen and available shipping methods.
*/
$query = '
query {
cart {
contents {
nodes {
key
}
}
availableShippingMethods {
packageDetails
supportsShippingCalculator
rates {
id
cost(format: RAW)
label
}
}
}
}
';
$actual = $I->sendGraphQLRequest( $query, null, [ 'woocommerce-session' => "Session {$session_token}" ] );
$I->assertQuerySuccessful(
$actual,
[
$I->expectField( 'cart.contents.nodes.#.key', $cart_item_key ),
$I->expectField( 'cart.availableShippingMethods', Signal::NOT_FALSY ),
$I->expectNode(
'cart.availableShippingMethods',
[
$I->expectField( 'packageDetails', \html_entity_decode( 'socks &times;2' ) ),
$I->expectField( 'supportsShippingCalculator', true ),
$I->expectNode(
'rates',
[
$I->expectField( 'cost', '0' ),
$I->expectField( 'label', 'Flat rate' ),
]
),
$I->expectNode(
'rates',
[
$I->expectField( 'cost', '0' ),
$I->expectField( 'label', 'Free shipping' ),
]
),
],
0
),
]
);
$chosen_shipping_method = $I->lodashGet( $actual, 'data.cart.availableShippingMethods.0.rates.0.id' );
/**
* Update shipping method to 'flat_rate' shipping.
*/
$mutation = '
mutation ($input: UpdateShippingMethodInput!){
updateShippingMethod(input: $input) {
cart {
availableShippingMethods {
packageDetails
supportsShippingCalculator
rates {
id
cost(format: RAW)
label
}
}
chosenShippingMethods
shippingTotal
shippingTax
subtotal
subtotalTax
total
}
}
}
';
$success = $I->sendGraphQLRequest(
$mutation,
[
'input' => [
'clientMutationId' => 'someId',
'shippingMethods' => [ $chosen_shipping_method ],
],
],
[ 'woocommerce-session' => "Session {$session_token}" ]
);
$I->assertQuerySuccessful(
$success,
[
$I->expectField(
'updateShippingMethod.cart.chosenShippingMethods.#',
$chosen_shipping_method
),
]
);
}
public function testStoreAPICartTokenGeneration( FunctionalTester $I ) {
// Set token type to 'store-api'
$I->setWooGraphQLSetting( 'set_session_token_type', 'store-api' );
/**
* Add item to the cart
*/
$success = $I->addToCart(
[
'clientMutationId' => 'someId',
'productId' => $this->product_catalog['t-shirt'],
'quantity' => 1,
]
);
$I->assertQuerySuccessful(
$success,
[
$I->expectField( 'addToCart.cartItem.key', Signal::NOT_NULL ),
]
);
/**
* Assert that Cart-Token header exists and woocommerce-session does NOT
*/
$I->seeHttpHeaderOnce( 'Cart-Token' );
$I->dontSeeHttpHeader( 'woocommerce-session' );
$cart_token = $I->grabHttpHeader( 'Cart-Token' );
// Decode token using Store API secret (same as build_cart_token uses)
$I->assertNotEmpty( $cart_token );
$parts = \Automattic\WooCommerce\StoreApi\Utilities\JsonWebToken::get_parts( $cart_token );
$I->assertNotEmpty( $parts->payload->iat );
$I->assertNotEmpty( $parts->payload->exp );
$I->assertNotEmpty( $parts->payload->user_id );
}
public function testBothTokenTypesGeneration( FunctionalTester $I ) {
// Set token type to 'both'
$I->setWooGraphQLSetting( 'set_session_token_type', 'both' );
/**
* Add item to the cart
*/
$success = $I->addToCart(
[
'clientMutationId' => 'someId',
'productId' => $this->product_catalog['t-shirt'],
'quantity' => 1,
]
);
$I->assertQuerySuccessful(
$success,
[
$I->expectField( 'addToCart.cartItem.key', Signal::NOT_NULL ),
]
);
/**
* Assert that both woocommerce-session and Cart-Token headers exist
*/
$I->seeHttpHeaderOnce( 'woocommerce-session' );
$I->seeHttpHeaderOnce( 'Cart-Token' );
$session_token = $I->grabHttpHeader( 'woocommerce-session' );
$cart_token = $I->grabHttpHeader( 'Cart-Token' );
// Decode legacy token
JWT::$leeway = 60;
$session_data = ! empty( $session_token )
? JWT::decode( $session_token, new Key( GRAPHQL_WOOCOMMERCE_SECRET_KEY, 'HS256' ) )
: null;
$I->assertNotEmpty( $session_data );
$I->assertNotEmpty( $session_data->data );
$I->assertNotEmpty( $session_data->data->customer_id );
// Decode Cart-Token using Store API secret (same as build_cart_token uses)
$I->assertNotEmpty( $cart_token );
$parts = \Automattic\WooCommerce\StoreApi\Utilities\JsonWebToken::get_parts( $cart_token );
$I->assertNotEmpty( $parts->payload->iat );
$I->assertNotEmpty( $parts->payload->exp );
$I->assertNotEmpty( $parts->payload->user_id );
// Verify both tokens reference the same customer
$I->assertEquals( $session_data->data->customer_id, $parts->payload->user_id );
}
/**
* Test that consecutive cart queries with the same session token
* do not clear the persisted cart contents.
*
* @see https://github.com/wp-graphql/wp-graphql-woocommerce/issues/1010
*/
public function testCartQueryDoesNotClearPersistedSession( FunctionalTester $I ) {
/**
* Add item to the cart.
*/
$success = $I->addToCart(
[
'clientMutationId' => 'someId',
'productId' => $this->product_catalog['t-shirt'],
'quantity' => 3,
]
);
$I->assertQuerySuccessful(
$success,
[
$I->expectField( 'addToCart.cartItem.key', Signal::NOT_NULL ),
]
);
$cart_item_key = $I->lodashGet( $success, 'data.addToCart.cartItem.key' );
/**
* Grab the session token.
*/
$I->seeHttpHeaderOnce( 'woocommerce-session' );
$session_token = $I->grabHttpHeader( 'woocommerce-session' );
$cart_query = '
query {
cart {
contents {
nodes {
key
quantity
}
}
}
}
';
/**
* First cart query — should return the item we just added.
*/
$first = $I->sendGraphQLRequest( $cart_query, null, [ 'woocommerce-session' => "Session {$session_token}" ] );
$I->assertQuerySuccessful(
$first,
[
$I->expectField( 'cart.contents.nodes.#.key', $cart_item_key ),
]
);
/**
* Second cart query with the same token — should still return the
* same cart contents. Before the fix, this would return an empty cart
* because get_cart_from_session() was not called after wc_load_cart().
*/
$second = $I->sendGraphQLRequest( $cart_query, null, [ 'woocommerce-session' => "Session {$session_token}" ] );
$I->assertQuerySuccessful(
$second,
[
$I->expectField( 'cart.contents.nodes.#.key', $cart_item_key ),
]
);
/**
* Third cart query — confirm stability across multiple reads.
*/
$third = $I->sendGraphQLRequest( $cart_query, null, [ 'woocommerce-session' => "Session {$session_token}" ] );
$I->assertQuerySuccessful(
$third,
[
$I->expectField( 'cart.contents.nodes.#.key', $cart_item_key ),
]
);
}
public function testLegacyTokenOnlyWhenSetToLegacy( FunctionalTester $I ) {
// Legacy mode is the default, so no need to set option
// But we'll set it explicitly for clarity
$I->setWooGraphQLSetting( 'set_session_token_type', 'legacy' );
/**
* Add item to the cart
*/
$success = $I->addToCart(
[
'clientMutationId' => 'someId',
'productId' => $this->product_catalog['t-shirt'],
'quantity' => 1,
]
);
$I->assertQuerySuccessful(
$success,
[
$I->expectField( 'addToCart.cartItem.key', Signal::NOT_NULL ),
]
);
/**
* Assert that only woocommerce-session header exists, NOT Cart-Token
*/
$I->seeHttpHeaderOnce( 'woocommerce-session' );
$I->dontSeeHttpHeader( 'Cart-Token' );
}
}