Commit d24999a34e2 for woocommerce
commit d24999a34e249aa63689b6ec7815bc2f3cc3856a
Author: Thomas Roberts <5656702+opr@users.noreply.github.com>
Date: Thu Mar 12 11:16:34 2026 +0000
Fix draft and password-protected products in Store API product routes (#63466)
* Exclude non-published products from Store API single product routes
The Store API's ProductsById and ProductsBySlug routes did not check
post_status, allowing draft and other non-published products to be
returned via the public API. Add a publish status check so these
routes return 404 for non-published products.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Add password protection support to Store API product routes
Password-protected products now redact description and short_description
in Store API responses when the password has not been provided. Adds
is_password_protected boolean field to the product schema. Single product
routes accept a password parameter that bypasses the protection for the
current request when correct.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Make is_password_protected an objective field independent of cookie state
Use $product->get_post_password() instead of post_password_required()
for is_password_protected so it always reflects whether the product has
a password set, regardless of whether the correct cookie is present.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Refactor password handling to match WP_REST_Posts_Controller pattern
Extract shared password logic into ProductPasswordTrait following the
same approach as WP core: can_access_password_content() validates the
password using hash_equals, check_password_required() acts as a filter
callback using a password_check_passed array, and the filter is properly
cleaned up. Both ProductsById and ProductsBySlug now use the trait.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Add changelog
* Fix PHPCS lint errors in ProductPasswordTrait
Remove generic type annotations from \WP_REST_Request params to match
codebase conventions and fix spacing/type hint lint errors.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix PHPCS alignment warnings and add PHPStan baseline entries
Realign array arrows in ProductSchema to accommodate the new
is_password_protected key being the longest in both arrays.
Fix variable assignment alignment. Add PHPStan baseline entries
for WP_REST_Request generic type in ProductPasswordTrait methods.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Fix PHPStan generic type errors in ProductPasswordTrait
Use @phpstan-param to specify WP_REST_Request generic type instead of
adding baseline entries. This matches the pattern used in
PushTokenRestController and keeps PHPCS happy.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Update changelog
* Remove ProductPasswordTrait
* Remove password as param on ID/Slug routes
* Remove unneeded tests
* Update tests following coderabbit feedback
* Remove invalid status from tests
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/fix-draft-products-store-api b/plugins/woocommerce/changelog/fix-draft-products-store-api
new file mode 100644
index 00000000000..023957c5122
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-draft-products-store-api
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Prevent draft products from being accessible via Store API and redact content for password-protected products
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/ProductsById.php b/plugins/woocommerce/src/StoreApi/Routes/V1/ProductsById.php
index 1ded0e2e6ce..6191c3c13eb 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/ProductsById.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/ProductsById.php
@@ -1,6 +1,7 @@
<?php // phpcs:ignore Generic.PHP.RequireStrictTypes.MissingDeclaration
namespace Automattic\WooCommerce\StoreApi\Routes\V1;
+use Automattic\WooCommerce\Enums\ProductStatus;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
use Automattic\WooCommerce\StoreApi\Utilities\ProductLinksTrait;
@@ -82,7 +83,7 @@ class ProductsById extends AbstractRoute {
protected function get_route_response( \WP_REST_Request $request ) {
$object = wc_get_product( (int) $request['id'] );
- if ( ! $object || 0 === $object->get_id() ) {
+ if ( ! $object || 0 === $object->get_id() || ProductStatus::PUBLISH !== $object->get_status() ) {
throw new RouteException( 'woocommerce_rest_product_invalid_id', __( 'Invalid product ID.', 'woocommerce' ), 404 );
}
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/ProductsBySlug.php b/plugins/woocommerce/src/StoreApi/Routes/V1/ProductsBySlug.php
index c1a040ec280..544ae09855a 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/ProductsBySlug.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/ProductsBySlug.php
@@ -1,6 +1,7 @@
<?php // phpcs:ignore Generic.PHP.RequireStrictTypes.MissingDeclaration
namespace Automattic\WooCommerce\StoreApi\Routes\V1;
+use Automattic\WooCommerce\Enums\ProductStatus;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
use Automattic\WooCommerce\StoreApi\Utilities\ProductLinksTrait;
@@ -87,7 +88,7 @@ class ProductsBySlug extends AbstractRoute {
$object = $this->get_product_variation_by_slug( $slug );
}
- if ( ! $object || 0 === $object->get_id() ) {
+ if ( ! $object || 0 === $object->get_id() || ProductStatus::PUBLISH !== $object->get_status() ) {
throw new RouteException( 'woocommerce_rest_product_invalid_slug', __( 'Invalid product slug.', 'woocommerce' ), 404 );
}
diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/ProductSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/ProductSchema.php
index 38ef7e1cece..53902d82433 100644
--- a/plugins/woocommerce/src/StoreApi/Schemas/V1/ProductSchema.php
+++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/ProductSchema.php
@@ -51,68 +51,68 @@ class ProductSchema extends AbstractSchema {
*/
public function get_properties() {
return [
- 'id' => [
+ 'id' => [
'description' => __( 'Unique identifier for the resource.', 'woocommerce' ),
'type' => 'integer',
'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
- 'name' => [
+ 'name' => [
'description' => __( 'Product name.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit', 'embed' ],
],
- 'slug' => [
+ 'slug' => [
'description' => __( 'Product slug.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit', 'embed' ],
],
- 'parent' => [
+ 'parent' => [
'description' => __( 'ID of the parent product, if applicable.', 'woocommerce' ),
'type' => 'integer',
'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
- 'type' => [
+ 'type' => [
'description' => __( 'Product type.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
- 'variation' => [
+ 'variation' => [
'description' => __( 'Product variation attributes, if applicable.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit', 'embed' ],
],
- 'permalink' => [
+ 'permalink' => [
'description' => __( 'Product URL.', 'woocommerce' ),
'type' => 'string',
'format' => 'uri',
'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
- 'short_description' => [
+ 'short_description' => [
'description' => __( 'Product short description in HTML format.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit', 'embed' ],
],
- 'description' => [
+ 'description' => [
'description' => __( 'Product full description in HTML format.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit', 'embed' ],
],
- 'on_sale' => [
+ 'on_sale' => [
'description' => __( 'Is the product on sale?', 'woocommerce' ),
'type' => 'boolean',
'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
- 'sku' => [
+ 'sku' => [
'description' => __( 'Unique identifier.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit', 'embed' ],
],
- 'prices' => [
+ 'prices' => [
'description' => __( 'Price data provided using the smallest unit of the currency.', 'woocommerce' ),
'type' => 'object',
'context' => [ 'view', 'edit', 'embed' ],
@@ -161,25 +161,25 @@ class ProductSchema extends AbstractSchema {
]
),
],
- 'price_html' => array(
+ 'price_html' => array(
'description' => __( 'Price string formatted as HTML.', 'woocommerce' ),
'type' => 'string',
'context' => array( 'view', 'edit', 'embed' ),
'readonly' => true,
),
- 'average_rating' => [
+ 'average_rating' => [
'description' => __( 'Reviews average rating.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
- 'review_count' => [
+ 'review_count' => [
'description' => __( 'Amount of reviews that the product has.', 'woocommerce' ),
'type' => 'integer',
'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
- 'images' => [
+ 'images' => [
'description' => __( 'List of images.', 'woocommerce' ),
'type' => 'array',
'context' => [ 'view', 'edit', 'embed' ],
@@ -188,7 +188,7 @@ class ProductSchema extends AbstractSchema {
'properties' => $this->image_attachment_schema->get_properties(),
],
],
- 'categories' => [
+ 'categories' => [
'description' => __( 'List of categories, if applicable.', 'woocommerce' ),
'type' => 'array',
'context' => [ 'view', 'edit', 'embed' ],
@@ -222,7 +222,7 @@ class ProductSchema extends AbstractSchema {
],
],
],
- 'tags' => [
+ 'tags' => [
'description' => __( 'List of tags, if applicable.', 'woocommerce' ),
'type' => 'array',
'context' => [ 'view', 'edit', 'embed' ],
@@ -256,7 +256,7 @@ class ProductSchema extends AbstractSchema {
],
],
],
- 'brands' => [
+ 'brands' => [
'description' => __( 'List of brands, if applicable.', 'woocommerce' ),
'type' => 'array',
'context' => [ 'view', 'edit', 'embed' ],
@@ -290,7 +290,7 @@ class ProductSchema extends AbstractSchema {
],
],
],
- 'attributes' => [
+ 'attributes' => [
'description' => __( 'List of attributes (taxonomy terms) assigned to the product. For variable products, these are mapped to variations (see the `variations` field).', 'woocommerce' ),
'type' => 'array',
'context' => [ 'view', 'edit', 'embed' ],
@@ -358,7 +358,7 @@ class ProductSchema extends AbstractSchema {
],
],
],
- 'variations' => [
+ 'variations' => [
'description' => __( 'List of variation IDs, if applicable.', 'woocommerce' ),
'type' => 'array',
'context' => [ 'view', 'edit', 'embed' ],
@@ -396,7 +396,7 @@ class ProductSchema extends AbstractSchema {
],
],
],
- 'grouped_products' => [
+ 'grouped_products' => [
'description' => __( 'List of grouped product IDs, if applicable.', 'woocommerce' ),
'type' => 'array',
'context' => [ 'view', 'edit', 'embed' ],
@@ -407,31 +407,31 @@ class ProductSchema extends AbstractSchema {
'readonly' => true,
],
],
- 'has_options' => [
+ 'has_options' => [
'description' => __( 'Does the product have additional options before it can be added to the cart?', 'woocommerce' ),
'type' => 'boolean',
'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
- 'is_purchasable' => [
+ 'is_purchasable' => [
'description' => __( 'Is the product purchasable?', 'woocommerce' ),
'type' => 'boolean',
'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
- 'is_in_stock' => [
+ 'is_in_stock' => [
'description' => __( 'Is the product in stock?', 'woocommerce' ),
'type' => 'boolean',
'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
- 'is_on_backorder' => [
+ 'is_on_backorder' => [
'description' => __( 'Is the product stock backordered? This will also return false if backorder notifications are turned off.', 'woocommerce' ),
'type' => 'boolean',
'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
- 'stock_availability' => [
+ 'stock_availability' => [
'description' => __( 'Information about the product\'s availability.', 'woocommerce' ),
'type' => 'object',
'context' => [ 'view', 'edit', 'embed' ],
@@ -451,19 +451,19 @@ class ProductSchema extends AbstractSchema {
],
],
],
- 'low_stock_remaining' => [
+ 'low_stock_remaining' => [
'description' => __( 'Quantity left in stock if stock is low, or null if not applicable.', 'woocommerce' ),
'type' => [ 'number', 'null' ],
'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
- 'sold_individually' => [
+ 'sold_individually' => [
'description' => __( 'If true, only one item of this product is allowed for purchase in a single order.', 'woocommerce' ),
'type' => 'boolean',
'context' => [ 'view', 'edit', 'embed' ],
'readonly' => true,
],
- 'weight' => [
+ 'weight' => [
'description' => sprintf(
/* translators: %s: weight unit */
__( 'Product weight (%s).', 'woocommerce' ),
@@ -473,7 +473,7 @@ class ProductSchema extends AbstractSchema {
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
- 'dimensions' => [
+ 'dimensions' => [
'description' => __( 'Product dimensions.', 'woocommerce' ),
'type' => 'object',
'context' => [ 'view', 'edit' ],
@@ -511,19 +511,19 @@ class ProductSchema extends AbstractSchema {
],
],
],
- 'formatted_weight' => [
+ 'formatted_weight' => [
'description' => __( 'Product weight formatted for display (e.g. "2.5 kg").', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
- 'formatted_dimensions' => [
+ 'formatted_dimensions' => [
'description' => __( 'Product dimensions formatted for display (e.g. "10 × 5 × 3 cm").', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'readonly' => true,
],
- 'add_to_cart' => [
+ 'add_to_cart' => [
'description' => __( 'Add to cart button parameters.', 'woocommerce' ),
'type' => 'object',
'context' => [ 'view', 'edit', 'embed' ],
@@ -574,7 +574,13 @@ class ProductSchema extends AbstractSchema {
],
],
],
- self::EXTENDING_KEY => $this->get_extended_schema( self::IDENTIFIER ),
+ 'is_password_protected' => [
+ 'description' => __( 'Whether the product requires a password to access its content.', 'woocommerce' ),
+ 'type' => 'boolean',
+ 'context' => [ 'view', 'edit', 'embed' ],
+ 'readonly' => true,
+ ],
+ self::EXTENDING_KEY => $this->get_extended_schema( self::IDENTIFIER ),
];
}
@@ -585,49 +591,53 @@ class ProductSchema extends AbstractSchema {
* @return array
*/
public function get_item_response( $product ) {
- $availability = ProductAvailabilityUtils::get_product_availability( $product );
+ $availability = ProductAvailabilityUtils::get_product_availability( $product );
+ $password_required = post_password_required( $product->get_id() );
+ $short_description = $password_required ? '' : $this->prepare_html_response( wc_format_content( wp_kses_post( $product->get_short_description() ) ) );
+ $description = $password_required ? '' : $this->prepare_html_response( wc_format_content( wp_kses_post( $product->get_description() ) ) );
+
return [
- 'id' => $product->get_id(),
- 'name' => $this->prepare_html_response( $product->get_title() ),
- 'slug' => $product->get_slug(),
- 'parent' => $product->get_parent_id(),
- 'type' => $product->get_type(),
- 'variation' => $this->prepare_html_response( $product->is_type( ProductType::VARIATION ) ? wc_get_formatted_variation( $product, true, true, false ) : '' ),
- 'permalink' => $product->get_permalink(),
- 'sku' => $this->prepare_html_response( $product->get_sku() ),
- 'short_description' => $this->prepare_html_response( wc_format_content( wp_kses_post( $product->get_short_description() ) ) ),
- 'description' => $this->prepare_html_response( wc_format_content( wp_kses_post( $product->get_description() ) ) ),
- 'on_sale' => $product->is_on_sale(),
- 'prices' => (object) $this->prepare_product_price_response( $product ),
- 'price_html' => $this->prepare_html_response( $product->get_price_html() ),
- 'average_rating' => (string) $product->get_average_rating(),
- 'review_count' => $product->get_review_count(),
- 'images' => $this->get_images( $product ),
- 'categories' => $this->get_term_list( $product, 'product_cat' ),
- 'tags' => $this->get_term_list( $product, 'product_tag' ),
- 'brands' => $this->get_term_list( $product, 'product_brand' ),
- 'attributes' => $this->get_attributes( $product ),
- 'variations' => $this->get_variations( $product ),
- 'grouped_products' => $this->get_grouped_products( $product ),
- 'has_options' => $product->has_options(),
- 'is_purchasable' => $product->is_purchasable(),
- 'is_in_stock' => $product->is_in_stock(),
- 'is_on_backorder' => ProductStockStatus::ON_BACKORDER === $product->get_stock_status(),
- 'low_stock_remaining' => $this->get_low_stock_remaining( $product ),
- 'stock_availability' => (object) array(
+ 'id' => $product->get_id(),
+ 'name' => $this->prepare_html_response( $product->get_title() ),
+ 'slug' => $product->get_slug(),
+ 'parent' => $product->get_parent_id(),
+ 'type' => $product->get_type(),
+ 'variation' => $this->prepare_html_response( $product->is_type( ProductType::VARIATION ) ? wc_get_formatted_variation( $product, true, true, false ) : '' ),
+ 'permalink' => $product->get_permalink(),
+ 'sku' => $this->prepare_html_response( $product->get_sku() ),
+ 'short_description' => $short_description,
+ 'description' => $description,
+ 'on_sale' => $product->is_on_sale(),
+ 'prices' => (object) $this->prepare_product_price_response( $product ),
+ 'price_html' => $this->prepare_html_response( $product->get_price_html() ),
+ 'average_rating' => (string) $product->get_average_rating(),
+ 'review_count' => $product->get_review_count(),
+ 'images' => $this->get_images( $product ),
+ 'categories' => $this->get_term_list( $product, 'product_cat' ),
+ 'tags' => $this->get_term_list( $product, 'product_tag' ),
+ 'brands' => $this->get_term_list( $product, 'product_brand' ),
+ 'attributes' => $this->get_attributes( $product ),
+ 'variations' => $this->get_variations( $product ),
+ 'grouped_products' => $this->get_grouped_products( $product ),
+ 'has_options' => $product->has_options(),
+ 'is_purchasable' => $product->is_purchasable(),
+ 'is_in_stock' => $product->is_in_stock(),
+ 'is_on_backorder' => ProductStockStatus::ON_BACKORDER === $product->get_stock_status(),
+ 'low_stock_remaining' => $this->get_low_stock_remaining( $product ),
+ 'stock_availability' => (object) array(
'text' => $availability['availability'] ?? '',
'class' => $availability['class'] ?? '',
),
- 'sold_individually' => $product->is_sold_individually(),
- 'weight' => $product->get_weight(),
- 'dimensions' => (object) [
+ 'sold_individually' => $product->is_sold_individually(),
+ 'weight' => $product->get_weight(),
+ 'dimensions' => (object) [
'length' => $product->get_length(),
'width' => $product->get_width(),
'height' => $product->get_height(),
],
- 'formatted_weight' => wc_format_weight( (float) $product->get_weight() ),
- 'formatted_dimensions' => html_entity_decode( wc_format_dimensions( (array) $product->get_dimensions( false ) ), ENT_QUOTES, get_bloginfo( 'charset' ) ),
- 'add_to_cart' => (object) array_merge(
+ 'formatted_weight' => wc_format_weight( (float) $product->get_weight() ),
+ 'formatted_dimensions' => html_entity_decode( wc_format_dimensions( (array) $product->get_dimensions( false ) ), ENT_QUOTES, get_bloginfo( 'charset' ) ),
+ 'add_to_cart' => (object) array_merge(
[
'text' => $this->prepare_html_response( $product->add_to_cart_text() ),
'description' => $this->prepare_html_response( $product->add_to_cart_description() ),
@@ -636,7 +646,8 @@ class ProductSchema extends AbstractSchema {
],
( new QuantityLimits() )->get_add_to_cart_limits( $product )
),
- self::EXTENDING_KEY => $this->get_extended_data( self::IDENTIFIER, $product ),
+ 'is_password_protected' => '' !== $product->get_post_password(),
+ self::EXTENDING_KEY => $this->get_extended_data( self::IDENTIFIER, $product ),
];
}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Products.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Products.php
index 49d5d3f2e0c..1e110f45cac 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Products.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Products.php
@@ -8,6 +8,7 @@ namespace Automattic\WooCommerce\Tests\Blocks\StoreApi\Routes;
use Automattic\WooCommerce\Tests\Blocks\StoreApi\Routes\ControllerTestCase;
use Automattic\WooCommerce\Tests\Blocks\Helpers\FixtureData;
use Automattic\WooCommerce\Tests\Blocks\Helpers\ValidateSchema;
+use Automattic\WooCommerce\Enums\ProductStatus;
use Automattic\WooCommerce\Enums\ProductStockStatus;
/**
@@ -530,6 +531,210 @@ class Products extends ControllerTestCase {
$this->assertContains( $related_product->get_id(), $product_ids );
}
+ /**
+ * Data provider for non-published product statuses.
+ *
+ * @return array<string, array{string}>
+ */
+ public function provider_non_published_statuses() {
+ return array(
+ 'draft' => array( ProductStatus::DRAFT ),
+ 'pending' => array( ProductStatus::PENDING ),
+ 'private' => array( ProductStatus::PRIVATE ),
+ 'trash' => array( ProductStatus::TRASH ),
+ 'auto-draft' => array( ProductStatus::AUTO_DRAFT ),
+ );
+ }
+
+ /**
+ * @testdox Non-published products should not be returned when queried by ID ($status).
+ * @dataProvider provider_non_published_statuses
+ *
+ * @param string $status The product status to test.
+ */
+ public function test_non_published_product_by_id_returns_404( $status ) {
+ $fixtures = new FixtureData();
+ $product = $fixtures->get_simple_product(
+ array(
+ 'name' => 'Non Published Product',
+ 'regular_price' => 10,
+ )
+ );
+ $product->set_status( $status );
+ $product->save();
+
+ $response = rest_get_server()->dispatch( new \WP_REST_Request( 'GET', '/wc/store/v1/products/' . $product->get_id() ) );
+
+ $this->assertEquals( 404, $response->get_status() );
+ }
+
+ /**
+ * @testdox Non-published products should not be included in the collection response ($status).
+ * @dataProvider provider_non_published_statuses
+ *
+ * @param string $status The product status to test.
+ */
+ public function test_non_published_products_excluded_from_collection( $status ) {
+ $fixtures = new FixtureData();
+ $product = $fixtures->get_simple_product(
+ array(
+ 'name' => 'Non Published Product In Collection',
+ 'regular_price' => 10,
+ )
+ );
+ $product->set_status( $status );
+ $product->save();
+
+ $response = rest_get_server()->dispatch( new \WP_REST_Request( 'GET', '/wc/store/v1/products' ) );
+ $data = $response->get_data();
+ $product_ids = array_map(
+ function ( $product ) {
+ return $product['id'];
+ },
+ $data
+ );
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertNotContains( $product->get_id(), $product_ids );
+ }
+
+ /**
+ * @testdox Non-published products should not be returned when queried by slug ($status).
+ * @dataProvider provider_non_published_statuses
+ *
+ * @param string $status The product status to test.
+ */
+ public function test_non_published_product_by_slug_returns_404( $status ) {
+ $fixtures = new FixtureData();
+ $product = $fixtures->get_simple_product(
+ array(
+ 'name' => 'Non Published Product By Slug',
+ 'regular_price' => 10,
+ )
+ );
+ $product->set_status( $status );
+ $product->save();
+
+ $response = rest_get_server()->dispatch( new \WP_REST_Request( 'GET', '/wc/store/v1/products/' . $product->get_slug() ) );
+
+ $this->assertEquals( 404, $response->get_status() );
+ }
+
+ /**
+ * @testdox Password-protected products in collection should have redacted content and is_password_protected true.
+ */
+ public function test_password_protected_product_redacts_content_in_collection() {
+ $fixtures = new FixtureData();
+ $product = $fixtures->get_simple_product(
+ array(
+ 'name' => 'Protected Product',
+ 'regular_price' => 10,
+ 'short_description' => 'Secret short desc',
+ 'description' => 'Secret full desc',
+ )
+ );
+
+ wp_update_post(
+ array(
+ 'ID' => $product->get_id(),
+ 'post_password' => 'testpass',
+ )
+ );
+
+ $response = rest_get_server()->dispatch( new \WP_REST_Request( 'GET', '/wc/store/v1/products' ) );
+ $data = $response->get_data();
+
+ $protected_product = null;
+ foreach ( $data as $item ) {
+ if ( $item['id'] === $product->get_id() ) {
+ $protected_product = $item;
+ break;
+ }
+ }
+
+ $this->assertNotNull( $protected_product );
+ $this->assertTrue( $protected_product['is_password_protected'] );
+ $this->assertArrayHasKey( 'description', $protected_product );
+ $this->assertSame( '', $protected_product['description'] );
+ $this->assertArrayHasKey( 'short_description', $protected_product );
+ $this->assertSame( '', $protected_product['short_description'] );
+ }
+
+ /**
+ * @testdox Password-protected product by ID should have redacted content.
+ */
+ public function test_password_protected_product_by_id_redacts_content() {
+ $fixtures = new FixtureData();
+ $product = $fixtures->get_simple_product(
+ array(
+ 'name' => 'Protected Product By ID',
+ 'regular_price' => 10,
+ 'short_description' => 'Secret short desc',
+ 'description' => 'Secret full desc',
+ )
+ );
+
+ wp_update_post(
+ array(
+ 'ID' => $product->get_id(),
+ 'post_password' => 'testpass',
+ )
+ );
+
+ $response = rest_get_server()->dispatch( new \WP_REST_Request( 'GET', '/wc/store/v1/products/' . $product->get_id() ) );
+ $data = $response->get_data();
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertTrue( $data['is_password_protected'] );
+ $this->assertArrayHasKey( 'description', $data );
+ $this->assertSame( '', $data['description'] );
+ $this->assertArrayHasKey( 'short_description', $data );
+ $this->assertSame( '', $data['short_description'] );
+ }
+
+ /**
+ * @testdox Password-protected product by slug should have redacted content.
+ */
+ public function test_password_protected_product_by_slug_redacts_content() {
+ $fixtures = new FixtureData();
+ $product = $fixtures->get_simple_product(
+ array(
+ 'name' => 'Protected Product By Slug',
+ 'regular_price' => 10,
+ 'short_description' => 'Secret short desc',
+ 'description' => 'Secret full desc',
+ )
+ );
+
+ wp_update_post(
+ array(
+ 'ID' => $product->get_id(),
+ 'post_password' => 'testpass',
+ )
+ );
+
+ $response = rest_get_server()->dispatch( new \WP_REST_Request( 'GET', '/wc/store/v1/products/' . $product->get_slug() ) );
+ $data = $response->get_data();
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertTrue( $data['is_password_protected'] );
+ $this->assertArrayHasKey( 'description', $data );
+ $this->assertSame( '', $data['description'] );
+ $this->assertArrayHasKey( 'short_description', $data );
+ $this->assertSame( '', $data['short_description'] );
+ }
+
+ /**
+ * @testdox Non-password-protected product should have is_password_protected false.
+ */
+ public function test_non_password_protected_product_has_false_flag() {
+ $response = rest_get_server()->dispatch( new \WP_REST_Request( 'GET', '/wc/store/v1/products/' . $this->products[0]->get_id() ) );
+ $data = $response->get_data();
+
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertFalse( $data['is_password_protected'] );
+ }
+
/**
* @testdox Related query parameter returns empty when no related products exist.
*/