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.
 	 */