Commit a68082f24d9 for woocommerce

commit a68082f24d98b9d73d731dd45e4d9aacf1bbbe73
Author: Darren Ethier <darren@roughsmootheng.in>
Date:   Fri Aug 7 16:20:13 2026 -0400

    Restore approved directory synchronization review prompt (#67491)

diff --git a/plugins/woocommerce/changelog/fix-67489-approved-directory-review b/plugins/woocommerce/changelog/fix-67489-approved-directory-review
new file mode 100644
index 00000000000..6e418fc98f7
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-67489-approved-directory-review
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Restore and harden the Site Health prompt after approved download directory synchronization.
diff --git a/plugins/woocommerce/src/Internal/Admin/SiteHealth.php b/plugins/woocommerce/src/Internal/Admin/SiteHealth.php
index 3e3b74e494d..072eb6ff9ef 100644
--- a/plugins/woocommerce/src/Internal/Admin/SiteHealth.php
+++ b/plugins/woocommerce/src/Internal/Admin/SiteHealth.php
@@ -13,6 +13,7 @@ use Automattic\WooCommerce\Internal\DataStores\Orders\DataSynchronizer;
 use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Register as Download_Directories;
 use Automattic\WooCommerce\Internal\Utilities\ProductUtil;
 use Automattic\WooCommerce\Utilities\OrderUtil;
+use WC_Admin_Notices;
 use WC_Admin_Status;
 use WC_Helper_Updater;
 use WC_Install;
@@ -362,6 +363,38 @@ class SiteHealth {
 				),
 			),
 			'woocommerce_approved_download_directories_sync' => array(
+				'label' => __( 'WooCommerce approved download directories', 'woocommerce' ),
+				'badge' => 'security',
+				'check' => fn() => ! WC_Admin_Notices::has_notice( 'download_directories_sync_complete' ),
+				'good'  => array(
+					'label'       => __( 'WooCommerce approved download directories do not require review', 'woocommerce' ),
+					'description' => __( 'There is no completed approved download directory synchronization waiting for review.', 'woocommerce' ),
+				),
+				'fail'  => array(
+					'label'       => __( 'WooCommerce approved download directories need review', 'woocommerce' ),
+					'description' => __( 'Approved product download directory synchronization has completed. Review the list to confirm downloadable product files remain protected.', 'woocommerce' ),
+					'actions'     => array(
+						array(
+							'url'   => admin_url( 'admin.php?page=wc-settings&tab=products&section=download_urls' ),
+							'label' => __( 'Review approved directories', 'woocommerce' ),
+						),
+						array(
+							'url'   => wp_nonce_url(
+								add_query_arg( 'wc-hide-notice', 'download_directories_sync_complete', admin_url( 'site-health.php' ) ),
+								'woocommerce_hide_notices_nonce',
+								'_wc_notice_nonce'
+							),
+							'label' => __( 'Mark as reviewed', 'woocommerce' ),
+						),
+						array(
+							'url'     => 'https://woocommerce.com/document/approved-download-directories',
+							'label'   => __( 'Learn more about approved directories', 'woocommerce' ),
+							'new_tab' => true,
+						),
+					),
+				),
+			),
+			'woocommerce_approved_download_directories_enforcement' => array(
 				'label' => __( 'WooCommerce approved download directories', 'woocommerce' ),
 				'badge' => 'security',
 				'check' => fn() => Download_Directories::MODE_ENABLED === wc_get_container()->get( Download_Directories::class )->get_mode(),
diff --git a/plugins/woocommerce/src/Internal/ProductDownloads/ApprovedDirectories/Admin/SyncUI.php b/plugins/woocommerce/src/Internal/ProductDownloads/ApprovedDirectories/Admin/SyncUI.php
index 261f13fbe90..23dc46e466c 100644
--- a/plugins/woocommerce/src/Internal/ProductDownloads/ApprovedDirectories/Admin/SyncUI.php
+++ b/plugins/woocommerce/src/Internal/ProductDownloads/ApprovedDirectories/Admin/SyncUI.php
@@ -108,7 +108,7 @@ class SyncUI {
 	public function cancel_sync() {
 		$this->security_check();
 		wc_get_logger()->log( 'info', __( 'Approved Download Directories sync: scan has been cancelled.', 'woocommerce' ) );
-		wc_get_container()->get( Synchronize::class )->stop();
+		wc_get_container()->get( Synchronize::class )->cancel();
 	}

 	/**
diff --git a/plugins/woocommerce/src/Internal/ProductDownloads/ApprovedDirectories/Synchronize.php b/plugins/woocommerce/src/Internal/ProductDownloads/ApprovedDirectories/Synchronize.php
index 5d214b54335..e80e11be5bd 100644
--- a/plugins/woocommerce/src/Internal/ProductDownloads/ApprovedDirectories/Synchronize.php
+++ b/plugins/woocommerce/src/Internal/ProductDownloads/ApprovedDirectories/Synchronize.php
@@ -2,6 +2,7 @@

 namespace Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories;

+use ActionScheduler_Store;
 use Exception;
 use Automattic\WooCommerce\Internal\Utilities\URL;
 use WC_Admin_Notices;
@@ -36,6 +37,11 @@ class Synchronize {
 	 */
 	public const SYNC_TASK_PROGRESS = 'wc_product_download_dir_sync_progress';

+	/**
+	 * Used to prevent an active synchronization task from continuing after cancellation.
+	 */
+	private const SYNC_TASK_CANCELLED = 'wc_product_download_dir_sync_cancelled';
+
 	/**
 	 * Number of downloadable products to be processed in each atomic sync task.
 	 */
@@ -118,27 +124,60 @@ class Synchronize {
 	 * @return bool
 	 */
 	public function start(): bool {
-		if ( null !== $this->queue->get_next( self::SYNC_TASK ) ) {
+		if ( $this->has_scheduled_task() ) {
 			wc_get_logger()->log( 'warning', __( 'Synchronization of approved product download directories is already in progress.', 'woocommerce' ) );
 			return false;
 		}

+		delete_option( self::SYNC_TASK_CANCELLED );
 		update_option( self::SYNC_TASK_PAGE, 1 );
 		$this->queue->schedule_single( time(), self::SYNC_TASK, array(), self::SYNC_TASK_GROUP );
 		wc_get_logger()->log( 'info', __( 'Approved Download Directories sync: new scan scheduled.', 'woocommerce' ) );
 		return true;
 	}

+	/**
+	 * Indicates whether a synchronization task is pending or running.
+	 */
+	private function has_scheduled_task(): bool {
+		foreach ( array( ActionScheduler_Store::STATUS_PENDING, ActionScheduler_Store::STATUS_RUNNING ) as $status ) {
+			$scheduled_tasks = $this->queue->search(
+				array(
+					'hook'     => self::SYNC_TASK,
+					'status'   => $status,
+					'per_page' => 1,
+				),
+				'ids'
+			);
+
+			if ( ! empty( $scheduled_tasks ) ) {
+				return true;
+			}
+		}
+
+		return false;
+	}
+
 	/**
 	 * Runs the synchronization task.
 	 */
 	public function run() {
+		if ( $this->is_cancelled() ) {
+			$this->clean_up();
+			return;
+		}
+
 		$products = $this->get_next_set_of_downloadable_products();

 		foreach ( $products as $product ) {
 			$this->process_product( $product );
 		}

+		if ( $this->is_cancelled() ) {
+			$this->clean_up();
+			return;
+		}
+
 		// Detect if we have reached the end of the task.
 		if ( count( $products ) < self::SYNC_TASK_BATCH_SIZE ) {
 			wc_get_logger()->log( 'info', __( 'Approved Download Directories sync: scan is complete!', 'woocommerce' ) );
@@ -158,10 +197,44 @@ class Synchronize {
 	}

 	/**
-	 * Stops/cancels the current synchronization task.
+	 * Completes the current synchronization task and marks its results for review.
+	 *
+	 * The persistent notice is also the acknowledgement state used by Site Health. Existing
+	 * sites can therefore keep a pending review across requests and WooCommerce updates.
 	 */
 	public function stop() {
+		if ( $this->is_cancelled() ) {
+			$this->clean_up();
+			return;
+		}
+
 		WC_Admin_Notices::add_notice( 'download_directories_sync_complete', true );
+		$this->clean_up();
+	}
+
+	/**
+	 * Cancels the current synchronization task without marking its results for review.
+	 *
+	 * @since 11.1.0
+	 */
+	public function cancel(): void {
+		update_option( self::SYNC_TASK_CANCELLED, true );
+		$this->clean_up();
+	}
+
+	/**
+	 * Indicates whether the current synchronization was cancelled.
+	 *
+	 * @phpstan-impure
+	 */
+	private function is_cancelled(): bool {
+		return (bool) get_option( self::SYNC_TASK_CANCELLED, false );
+	}
+
+	/**
+	 * Cleans up synchronization state and scheduled actions.
+	 */
+	private function clean_up(): void {
 		delete_option( self::SYNC_TASK_PAGE );
 		delete_option( self::SYNC_TASK_PROGRESS );
 		$this->queue->cancel( self::SYNC_TASK );
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/SiteHealthTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/SiteHealthTest.php
index 7f94ae2e01d..9e50d59fa7d 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/SiteHealthTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/SiteHealthTest.php
@@ -5,6 +5,7 @@ namespace Automattic\WooCommerce\Tests\Internal\Admin;

 use Automattic\WooCommerce\Internal\Admin\SiteHealth;
 use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Register as Download_Directories;
+use WC_Admin_Notices;
 use WC_Unit_Test_Case;
 use WP_Error;

@@ -27,6 +28,7 @@ class SiteHealthTest extends WC_Unit_Test_Case {
 		$this->sut = new SiteHealth();
 		delete_transient( '_woocommerce_upload_directory_status' );
 		delete_option( 'wc_downloads_approved_directories_mode' );
+		WC_Admin_Notices::remove_notice( 'download_directories_sync_complete', true );
 	}

 	/**
@@ -35,6 +37,7 @@ class SiteHealthTest extends WC_Unit_Test_Case {
 	public function tearDown(): void {
 		delete_transient( '_woocommerce_upload_directory_status' );
 		delete_option( 'wc_downloads_approved_directories_mode' );
+		WC_Admin_Notices::remove_notice( 'download_directories_sync_complete', true );
 		parent::tearDown();
 	}

@@ -44,7 +47,7 @@ class SiteHealthTest extends WC_Unit_Test_Case {
 	public function test_approved_download_directories_check_is_good_when_rules_are_enforced(): void {
 		wc_get_container()->get( Download_Directories::class )->set_mode( Download_Directories::MODE_ENABLED );

-		$result = $this->sut->run_test( 'woocommerce_approved_download_directories_sync' );
+		$result = $this->sut->run_test( 'woocommerce_approved_download_directories_enforcement' );

 		$this->assertSame( 'good', $result['status'], 'Enabled approved directory rules should pass the Site Health check.' );
 		$this->assertSame( 'WooCommerce approved download directory rules are enforced', $result['label'], 'The result should report that approved directory rules are enforced.' );
@@ -54,7 +57,7 @@ class SiteHealthTest extends WC_Unit_Test_Case {
 	 * @testdox Approved download directories check recommends enabling rules when they are not enforced.
 	 */
 	public function test_approved_download_directories_check_recommends_enabling_rules_when_not_enforced(): void {
-		$result = $this->sut->run_test( 'woocommerce_approved_download_directories_sync' );
+		$result = $this->sut->run_test( 'woocommerce_approved_download_directories_enforcement' );

 		$this->assertSame( 'recommended', $result['status'], 'Disabled approved directory rules should require attention in Site Health.' );
 		$this->assertSame( 'WooCommerce approved download directory rules are not enforced', $result['label'], 'The result should report that approved directory rules are not enforced.' );
@@ -62,6 +65,33 @@ class SiteHealthTest extends WC_Unit_Test_Case {
 		$this->assertStringContainsString( 'section=download_urls', $result['actions'], 'The result should link to the approved directory settings.' );
 	}

+	/**
+	 * @testdox Approved download directories check recommends review after synchronization when rules are enforced.
+	 */
+	public function test_approved_download_directories_check_recommends_review_after_synchronization(): void {
+		wc_get_container()->get( Download_Directories::class )->set_mode( Download_Directories::MODE_ENABLED );
+		WC_Admin_Notices::add_notice( 'download_directories_sync_complete', true );
+
+		$result = $this->sut->run_test( 'woocommerce_approved_download_directories_sync' );
+
+		$this->assertSame( 'recommended', $result['status'], 'A completed synchronization should require review even when approved directory rules are enforced.' );
+		$this->assertSame( 'WooCommerce approved download directories need review', $result['label'], 'The result should prompt the merchant to review synchronized directories.' );
+		$this->assertSame( '<p>Approved product download directory synchronization has completed. Review the list to confirm downloadable product files remain protected.</p>', $result['description'], 'The result should describe synchronization completion without claiming that new directories were found.' );
+		$this->assertStringContainsString( 'section=download_urls', $result['actions'], 'The result should link to the approved directory settings.' );
+		$this->assertStringContainsString( 'wc-hide-notice=download_directories_sync_complete', $result['actions'], 'The result should let the merchant mark the synchronization as reviewed.' );
+	}
+
+	/**
+	 * @testdox Approved download directories check is good when no synchronization is waiting for review.
+	 */
+	public function test_approved_download_directories_check_is_good_without_completed_synchronization(): void {
+		$result = $this->sut->run_test( 'woocommerce_approved_download_directories_sync' );
+
+		$this->assertSame( 'good', $result['status'], 'No completed synchronization should require review.' );
+		$this->assertSame( 'WooCommerce approved download directories do not require review', $result['label'], 'The result should confirm that synchronized directories do not require review.' );
+		$this->assertSame( '<p>There is no completed approved download directory synchronization waiting for review.</p>', $result['description'], 'The result should confirm that no synchronization review is pending.' );
+	}
+
 	/**
 	 * @testdox Upload directory protection check is inconclusive when the HTTP request fails.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductDownloads/ApprovedDirectories/SynchronizeTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductDownloads/ApprovedDirectories/SynchronizeTest.php
index 8ac2d6d0350..bd020f97522 100644
--- a/plugins/woocommerce/tests/php/src/Internal/ProductDownloads/ApprovedDirectories/SynchronizeTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductDownloads/ApprovedDirectories/SynchronizeTest.php
@@ -3,8 +3,10 @@ declare( strict_types = 1 );

 namespace Automattic\WooCommerce\Tests\Internal\ProductDownloads\ApprovedDirectories;

+use ActionScheduler_Store;
 use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Synchronize;
 use Automattic\WooCommerce\Proxies\LegacyProxy;
+use WC_Admin_Notices;
 use WC_Queue_Interface;
 use WC_Unit_Test_Case;

@@ -12,6 +14,11 @@ use WC_Unit_Test_Case;
  * Tests for the Product Downloads Allowed Directories synchronization utility.
  */
 class SynchronizeTest extends WC_Unit_Test_Case {
+	/**
+	 * Option used to mark a synchronization as cancelled.
+	 */
+	private const SYNC_TASK_CANCELLED = 'wc_product_download_dir_sync_cancelled';
+
 	/**
 	 * @var Synchronize
 	 */
@@ -23,6 +30,18 @@ class SynchronizeTest extends WC_Unit_Test_Case {
 	public function setUp(): void {
 		parent::setUp();
 		$this->sut = wc_get_container()->get( Synchronize::class );
+		delete_option( self::SYNC_TASK_CANCELLED );
+		WC_Admin_Notices::remove_notice( 'download_directories_sync_complete', true );
+	}
+
+	/**
+	 * Clean up after each test.
+	 */
+	public function tearDown(): void {
+		$this->sut->cancel();
+		delete_option( self::SYNC_TASK_CANCELLED );
+		WC_Admin_Notices::remove_notice( 'download_directories_sync_complete', true );
+		parent::tearDown();
 	}

 	/**
@@ -34,9 +53,9 @@ class SynchronizeTest extends WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox Ensure basic controls to start and stop synchronization behave as expected.
+	 * @testdox Ensure basic controls to start and cancel synchronization behave as expected.
 	 */
-	public function test_basic_synchronization_controls() {
+	public function test_basic_synchronization_controls(): void {
 		$this->sut->start();
 		$this->assertTrue(
 			$this->sut->in_progress(),
@@ -48,22 +67,61 @@ class SynchronizeTest extends WC_Unit_Test_Case {
 			'If a download directory synchronization process is already in progress, additional concurrent sync processes cannot be created.'
 		);

+		$this->sut->cancel();
 		$this->assertFalse(
-			$this->sut->start(),
-			'Synchronization process can be cancelled before it completes.'
+			$this->sut->in_progress(),
+			'Synchronization can be cancelled before it completes.'
 		);
-
-		$this->sut->stop();
 		$this->assertNull(
 			wc_get_container()->get( LegacyProxy::class )->get_instance_of( WC_Queue_Interface::class )->get_next( Synchronize::SYNC_TASK ),
 			'Once synchronization has been cancelled, any related scheduled actions will also have been cleaned up.'
 		);
+		$this->assertFalse(
+			WC_Admin_Notices::has_notice( 'download_directories_sync_complete' ),
+			'Cancelling synchronization should not create a completed synchronization review notice.'
+		);
+
+		$this->assertTrue( $this->sut->start(), 'A new synchronization should be able to start after cancellation.' );
+		$this->sut->run();
+		$this->assertTrue(
+			WC_Admin_Notices::has_notice( 'download_directories_sync_complete' ),
+			'A new synchronization should complete normally after a previous synchronization was cancelled.'
+		);
+	}
+
+	/**
+	 * @testdox Scheduled task detection uses scalar queue statuses supported by the queue interface.
+	 */
+	public function test_scheduled_task_detection_uses_scalar_statuses(): void {
+		$searched_statuses = array();
+		$queue             = $this->createMock( WC_Queue_Interface::class );
+		$queue->method( 'search' )
+			->willReturnCallback(
+				function ( array $args, string $return_format ) use ( &$searched_statuses ): array {
+					$searched_statuses[] = $args['status'];
+					$this->assertSame( 'ids', $return_format );
+
+					return ActionScheduler_Store::STATUS_RUNNING === $args['status'] ? array( 123 ) : array();
+				}
+			);
+
+		$sut            = new Synchronize();
+		$queue_property = new \ReflectionProperty( $sut, 'queue' );
+		$queue_property->setAccessible( true );
+		$queue_property->setValue( $sut, $queue );
+
+		$this->assertFalse( $sut->start(), 'A running synchronization task should prevent another synchronization from starting.' );
+		$this->assertSame(
+			array( ActionScheduler_Store::STATUS_PENDING, ActionScheduler_Store::STATUS_RUNNING ),
+			$searched_statuses,
+			'Scheduled task detection should query each status as a scalar value.'
+		);
 	}

 	/**
 	 * @testdox Verify expected logging and clean-up take place during and following synchronization of download directories.
 	 */
-	public function test_sync_process() {
+	public function test_sync_process(): void {
 		$logged_messages = array();

 		$log_watcher = function ( string $logged_message ) use ( &$logged_messages ) {
@@ -87,5 +145,69 @@ class SynchronizeTest extends WC_Unit_Test_Case {
 			$logged_messages,
 			'We expect that completion of the synchronization process will have been recorded in the log.'
 		);
+
+		$this->assertTrue(
+			WC_Admin_Notices::has_notice( 'download_directories_sync_complete' ),
+			'Completed synchronization should create a persistent review notice.'
+		);
+	}
+
+	/**
+	 * @testdox Cancelling synchronization preserves a review notice that was already pending.
+	 */
+	public function test_cancellation_preserves_pending_review_notice(): void {
+		WC_Admin_Notices::add_notice( 'download_directories_sync_complete', true );
+
+		$this->sut->start();
+		$this->sut->cancel();
+
+		$this->assertTrue(
+			WC_Admin_Notices::has_notice( 'download_directories_sync_complete' ),
+			'Cancelling a later synchronization should not clear an existing pending review state.'
+		);
+	}
+
+	/**
+	 * @testdox Cancelling an active batch does not mark synchronization as complete.
+	 */
+	public function test_cancellation_during_active_batch_does_not_mark_sync_complete(): void {
+		$cancelled           = false;
+		$restart_result      = null;
+		$cancel_during_batch = function () use ( &$cancelled, &$restart_result ): void {
+			if ( ! $cancelled ) {
+				$cancelled = true;
+				$this->sut->cancel();
+				$restart_result = $this->sut->start();
+			}
+		};
+
+		$this->sut->start();
+		$store           = ActionScheduler_Store::instance();
+		$claim           = $store->stake_claim( 1, null, array( Synchronize::SYNC_TASK ) );
+		$claimed_actions = $claim->get_actions();
+		$this->assertCount( 1, $claimed_actions, 'The synchronization action should be marked as running for the test.' );
+		$store->log_execution( $claimed_actions[0] );
+		add_action( 'update_option_' . Synchronize::SYNC_TASK_PAGE, $cancel_during_batch, 10, 0 );
+
+		try {
+			$this->sut->run();
+		} finally {
+			remove_action( 'update_option_' . Synchronize::SYNC_TASK_PAGE, $cancel_during_batch, 10 );
+			$store->mark_complete( $claimed_actions[0] );
+			$store->release_claim( $claim );
+		}
+
+		$this->assertTrue( $cancelled, 'The test should cancel synchronization while a batch is active.' );
+		$this->assertFalse( $restart_result, 'A new synchronization should not start before the cancelled active batch returns.' );
+		$this->assertFalse( $this->sut->in_progress(), 'An active batch should clean up synchronization state after cancellation.' );
+		$this->assertFalse(
+			WC_Admin_Notices::has_notice( 'download_directories_sync_complete' ),
+			'An active batch should not create a completion notice after cancellation.'
+		);
+		$this->assertNull(
+			wc_get_container()->get( LegacyProxy::class )->get_instance_of( WC_Queue_Interface::class )->get_next( Synchronize::SYNC_TASK ),
+			'An active batch should not schedule more synchronization work after cancellation.'
+		);
+		$this->assertTrue( $this->sut->start(), 'A new synchronization should start after the cancelled active batch has returned.' );
 	}
 }