Commit 7a07aac756d for woocommerce

commit 7a07aac756d18ac5b4567b8d5a59d64147666cb0
Author: Michal Iwanow <4765119+mcliwanow@users.noreply.github.com>
Date:   Wed Oct 28 11:07:40 2026 +0100

    Offer the Update Manager as a download on local stores (#70236)

    * Offer the Update Manager as a download on local stores

    WooCommerce.com installs the Update Manager by calling the store's REST API, which it can't reach on localhost, private IPs or local dev domains. On those stores the notice now links to the zip with upload steps and the missing plugin no longer adds to the update badge. The failed-install notice in My Subscriptions links to My Subscriptions on WooCommerce.com instead of Downloads.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Keep one-click install when the helper targets another WooCommerce.com

    A local store connected to a local or staging WooCommerce.com (via the woocommerce_helper_api_base or woo_com_base_url filters) can be reached, so the manual download path only applies when both point at production WooCommerce.com.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Address review: @since tags, notice copy, scoped test filter

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Simplify notice view and local-store check

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Treat trailing-dot hosts and shared-address IPs as local

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Address review: range check for shared IPv4, sync note, @internal

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Offer the Update Manager download in the Marketplace and plugin row on local stores

    When WooCommerce.com can't reach the store to push the one-click install,
    the plugin row, the My subscriptions notice and the "Access your updates"
    modal now offer the download and the Add Plugins upload screen instead of
    the Install button, matching the admin notice.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Only show the Update Manager upload link to users who can install plugins

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Keep counting a missing Update Manager on local stores

    The My Subscriptions notice now offers the download and upload there, so the
    count leads to a flow that works.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    ---------

    Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
    Co-authored-by: Herman <KokkieH@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/wccom-3130-local-store-update-manager-install b/plugins/woocommerce/changelog/wccom-3130-local-store-update-manager-install
new file mode 100644
index 00000000000..d4c9c60ea6b
--- /dev/null
+++ b/plugins/woocommerce/changelog/wccom-3130-local-store-update-manager-install
@@ -0,0 +1,4 @@
+Significance: patch
+Type: tweak
+
+On local stores, offer the WooCommerce.com Update Manager as a download instead of a one-click install that can't reach them.
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/actions/install.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/actions/install.tsx
index 80298cbe2c1..c45cbee6a5f 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/actions/install.tsx
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/actions/install.tsx
@@ -21,6 +21,7 @@ import {
 import { Subscription } from '../../types';
 import { installingStore } from '../../../../contexts/install-store';
 import { NoticeStatus } from '../../../../contexts/types';
+import { MARKETPLACE_RENEW_SUBSCRIPTION_PATH } from '../../../constants';

 type ButtonProps = ComponentProps< typeof Button >;

@@ -76,7 +77,7 @@ export default function Install( props: InstallProps ) {
 								'Download and install manually',
 								'woocommerce'
 							),
-							url: 'https://woocommerce.com/my-account/downloads/',
+							url: MARKETPLACE_RENEW_SUBSCRIPTION_PATH,
 							onClick: () => {},
 						},
 					],
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/woo-update-manager-plugin/install-woo-connect-modal.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/woo-update-manager-plugin/install-woo-connect-modal.tsx
index 1b41d92e43a..07f86642a60 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/components/woo-update-manager-plugin/install-woo-connect-modal.tsx
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/woo-update-manager-plugin/install-woo-connect-modal.tsx
@@ -23,6 +23,25 @@ interface ConnectProps {
 export default function InstallWooConnectModal( props: ConnectProps ) {
 	const wccomSettings = getAdminSetting( 'wccomHelper', {} );
 	if ( ! wccomSettings?.wooUpdateManagerInstalled ) {
+		const isManualInstall = !! wccomSettings?.wooUpdateManagerManualInstall;
+		const message = isManualInstall
+			? sprintf(
+					// translators: %s is the product version number (e.g. 1.0.2).
+					__(
+						'Version %s is available. To access this update, please first <b>download the WooCommerce.com Update Manager</b> extension and upload it on the Add Plugins screen. WooCommerce.com can’t reach local sites, so it can’t install it for you.',
+						'woocommerce'
+					),
+					props.subscription.version
+				)
+			: sprintf(
+					// translators: %s is the product version number (e.g. 1.0.2).
+					__(
+						'Version %s is available. To access this update, please first <b>install the WooCommerce.com Update Manager</b> extension. Alternatively, you can download and install it manually.',
+						'woocommerce'
+					),
+					props.subscription.version
+				);
+
 		return (
 			<Modal
 				title={ __( 'Access your updates', 'woocommerce' ) }
@@ -33,32 +52,34 @@ export default function InstallWooConnectModal( props: ConnectProps ) {
 				overlayClassName="woocommerce-marketplace__header-account-modal-overlay"
 			>
 				<p className="woocommerce-marketplace__header-account-modal-text">
-					<span
-						dangerouslySetInnerHTML={ sanitizeHTML(
-							sprintf(
-								// translators: %s is the product version number (e.g. 1.0.2).
-								__(
-									'Version %s is available. To access this update, please first <b>install the WooCommerce.com Update Manager</b> extension. Alternatively, you can download and install it manually.',
-									'woocommerce'
-								),
-								props.subscription.version
-							)
-						) }
-					/>
+					<span dangerouslySetInnerHTML={ sanitizeHTML( message ) } />
 				</p>
 				<ButtonGroup className="woocommerce-marketplace__header-account-modal-button-group">
 					<Button
 						href={ WOO_CONNECT_PLUGIN_DOWNLOAD_URL }
-						variant="secondary"
+						variant={ isManualInstall ? 'primary' : 'secondary' }
 					>
 						{ __( 'Download', 'woocommerce' ) }
 					</Button>
-					<Button
-						href={ wccomSettings?.wooUpdateManagerInstallUrl }
-						variant="primary"
-					>
-						{ __( 'Install', 'woocommerce' ) }
-					</Button>
+					{ isManualInstall &&
+						wccomSettings?.wooUpdateManagerUploadUrl && (
+							<Button
+								href={
+									wccomSettings?.wooUpdateManagerUploadUrl
+								}
+								variant="secondary"
+							>
+								{ __( 'Upload plugin', 'woocommerce' ) }
+							</Button>
+						) }
+					{ ! isManualInstall && (
+						<Button
+							href={ wccomSettings?.wooUpdateManagerInstallUrl }
+							variant="primary"
+						>
+							{ __( 'Install', 'woocommerce' ) }
+						</Button>
+					) }
 				</ButtonGroup>
 			</Modal>
 		);
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/woo-update-manager-plugin/plugin-install-notice.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/woo-update-manager-plugin/plugin-install-notice.tsx
index 32e0d45340d..c1509a29e27 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/components/woo-update-manager-plugin/plugin-install-notice.tsx
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/woo-update-manager-plugin/plugin-install-notice.tsx
@@ -35,30 +35,52 @@ export default function PluginInstallNotice( props: { selectedTab: string } ) {
 		! wccomSettings?.wooUpdateManagerActive &&
 		! wccomSettings?.wooUpdateManagerInstalled
 	) {
+		const isManualInstall = !! wccomSettings?.wooUpdateManagerManualInstall;
+
 		return (
 			<section className="woocommerce-marketplace__woo-update-manager-plugin__notices">
 				<Notice status="error" isDismissible={ false }>
 					<span
 						dangerouslySetInnerHTML={ sanitizeHTML(
-							__(
-								'Please install the <b>WooCommerce.com Update Manager</b> to continue receiving the updates and streamlined support included in your WooCommerce.com subscriptions.<br/>Alternatively, you can download and install it manually.',
-								'woocommerce'
-							)
+							isManualInstall
+								? __(
+										'Please download the <b>WooCommerce.com Update Manager</b> and upload it on the Add Plugins screen to continue receiving the updates and streamlined support included in your WooCommerce.com subscriptions.<br/>WooCommerce.com can’t reach local sites, so it can’t install it for you.',
+										'woocommerce'
+									)
+								: __(
+										'Please install the <b>WooCommerce.com Update Manager</b> to continue receiving the updates and streamlined support included in your WooCommerce.com subscriptions.<br/>Alternatively, you can download and install it manually.',
+										'woocommerce'
+									)
 						) }
 					></span>
 					<div className="components-notice__buttons">
-						<Button
-							href={ wccomSettings?.wooUpdateManagerInstallUrl }
-							variant="secondary"
-						>
-							{ __( 'Install', 'woocommerce' ) }
-						</Button>
+						{ ! isManualInstall && (
+							<Button
+								href={
+									wccomSettings?.wooUpdateManagerInstallUrl
+								}
+								variant="secondary"
+							>
+								{ __( 'Install', 'woocommerce' ) }
+							</Button>
+						) }
 						<Button
 							href={ WOO_CONNECT_PLUGIN_DOWNLOAD_URL }
-							variant="link"
+							variant={ isManualInstall ? 'secondary' : 'link' }
 						>
 							{ __( 'Download', 'woocommerce' ) }
 						</Button>
+						{ isManualInstall &&
+							wccomSettings?.wooUpdateManagerUploadUrl && (
+								<Button
+									href={
+										wccomSettings?.wooUpdateManagerUploadUrl
+									}
+									variant="link"
+								>
+									{ __( 'Upload plugin', 'woocommerce' ) }
+								</Button>
+							) }
 					</div>
 				</Notice>
 			</section>
diff --git a/plugins/woocommerce/includes/admin/helper/class-wc-helper-admin.php b/plugins/woocommerce/includes/admin/helper/class-wc-helper-admin.php
index 909730592d1..10756430f60 100644
--- a/plugins/woocommerce/includes/admin/helper/class-wc-helper-admin.php
+++ b/plugins/woocommerce/includes/admin/helper/class-wc-helper-admin.php
@@ -74,22 +74,24 @@ class WC_Helper_Admin {
 		$blog_name = get_bloginfo( 'name' );

 		$settings['wccomHelper'] = array(
-			'isConnected'                => WC_Helper::is_site_connected(),
-			'connectURL'                 => self::get_connection_url(),
-			'reConnectURL'               => self::get_connection_url( true ),
-			'userEmail'                  => $auth_user_email,
-			'userAvatar'                 => get_avatar_url( $auth_user_email, array( 'size' => '48' ) ),
-			'storeCountry'               => wc_get_base_location()['country'],
-			'storeName'                  => $blog_name ? $blog_name : '',
-			'inAppPurchaseURLParams'     => WC_Admin_Addons::get_in_app_purchase_url_params(),
-			'installedProducts'          => $installed_products,
-			'mySubscriptionsTabLoaded'   => WC_Helper_Options::get( 'my_subscriptions_tab_loaded' ),
-			'wooUpdateManagerInstalled'  => WC_Woo_Update_Manager_Plugin::is_plugin_installed(),
-			'wooUpdateManagerActive'     => WC_Woo_Update_Manager_Plugin::is_plugin_active(),
-			'wooUpdateManagerInstallUrl' => WC_Woo_Update_Manager_Plugin::generate_install_url(),
-			'wooUpdateManagerPluginSlug' => WC_Woo_Update_Manager_Plugin::WOO_UPDATE_MANAGER_SLUG,
-			'dismissNoticeNonce'         => wp_create_nonce( 'dismiss_notice' ),
-			'trackingAllowed'            => 'yes' === get_option( 'woocommerce_allow_tracking' ),
+			'isConnected'                   => WC_Helper::is_site_connected(),
+			'connectURL'                    => self::get_connection_url(),
+			'reConnectURL'                  => self::get_connection_url( true ),
+			'userEmail'                     => $auth_user_email,
+			'userAvatar'                    => get_avatar_url( $auth_user_email, array( 'size' => '48' ) ),
+			'storeCountry'                  => wc_get_base_location()['country'],
+			'storeName'                     => $blog_name ? $blog_name : '',
+			'inAppPurchaseURLParams'        => WC_Admin_Addons::get_in_app_purchase_url_params(),
+			'installedProducts'             => $installed_products,
+			'mySubscriptionsTabLoaded'      => WC_Helper_Options::get( 'my_subscriptions_tab_loaded' ),
+			'wooUpdateManagerInstalled'     => WC_Woo_Update_Manager_Plugin::is_plugin_installed(),
+			'wooUpdateManagerActive'        => WC_Woo_Update_Manager_Plugin::is_plugin_active(),
+			'wooUpdateManagerInstallUrl'    => WC_Woo_Update_Manager_Plugin::generate_install_url(),
+			'wooUpdateManagerManualInstall' => WC_Woo_Update_Manager_Plugin::needs_manual_install(),
+			'wooUpdateManagerUploadUrl'     => current_user_can( 'install_plugins' ) ? network_admin_url( 'plugin-install.php?tab=upload' ) : '',
+			'wooUpdateManagerPluginSlug'    => WC_Woo_Update_Manager_Plugin::WOO_UPDATE_MANAGER_SLUG,
+			'dismissNoticeNonce'            => wp_create_nonce( 'dismiss_notice' ),
+			'trackingAllowed'               => 'yes' === get_option( 'woocommerce_allow_tracking' ),
 		);

 		// This data is only used in the `Extensions` screen, so only populate it there.
diff --git a/plugins/woocommerce/includes/admin/helper/class-wc-helper-updater.php b/plugins/woocommerce/includes/admin/helper/class-wc-helper-updater.php
index 9061a75ae59..049091f73b0 100644
--- a/plugins/woocommerce/includes/admin/helper/class-wc-helper-updater.php
+++ b/plugins/woocommerce/includes/admin/helper/class-wc-helper-updater.php
@@ -759,17 +759,26 @@ class WC_Helper_Updater {
 		}

 		if ( ! WC_Woo_Update_Manager_Plugin::is_plugin_installed() ) {
+			if ( WC_Woo_Update_Manager_Plugin::needs_manual_install() ) {
+				/* translators: 1: Woo Update Manager plugin download URL */
+				$message = __( ' <a href="%1$s">Download the WooCommerce.com Update Manager</a> and upload it to update.', 'woocommerce' );
+				$url     = WC_Woo_Update_Manager_Plugin::WOO_UPDATE_MANAGER_DOWNLOAD_URL;
+			} else {
+				/* translators: 1: Woo Update Manager plugin install URL */
+				$message = __( ' <a href="%1$s">Install WooCommerce.com Update Manager</a> to update.', 'woocommerce' );
+				$url     = WC_Woo_Update_Manager_Plugin::generate_install_url();
+			}
+
 			printf(
 				wp_kses(
-					/* translators: 1: Woo Update Manager plugin install URL */
-					__( ' <a href="%1$s">Install WooCommerce.com Update Manager</a> to update.', 'woocommerce' ),
+					$message,
 					array(
 						'a' => array(
 							'href' => array(),
 						),
 					)
 				),
-				esc_url( WC_Woo_Update_Manager_Plugin::generate_install_url() ),
+				esc_url( $url ),
 			);
 			return;
 		}
diff --git a/plugins/woocommerce/includes/admin/helper/class-wc-woo-update-manager-plugin.php b/plugins/woocommerce/includes/admin/helper/class-wc-woo-update-manager-plugin.php
index 39b16d118a0..383263df8da 100644
--- a/plugins/woocommerce/includes/admin/helper/class-wc-woo-update-manager-plugin.php
+++ b/plugins/woocommerce/includes/admin/helper/class-wc-woo-update-manager-plugin.php
@@ -22,6 +22,12 @@ class WC_Woo_Update_Manager_Plugin {
 	const WOO_UPDATE_MANAGER_DOWNLOAD_URL     = 'https://woocommerce.com/product-download/woo-update-manager';
 	const WOO_UPDATE_MANAGER_SLUG             = 'woo-update-manager';

+	/**
+	 * Domains, and their subdomains, that WooCommerce.com treats as local.
+	 * Keep in sync with `Site_Reachability::host_reason()` in WooCommerce.com's in-app-purchase plugin.
+	 */
+	private const LOCAL_DOMAINS = array( 'localhost', 'local', 'test', 'lan', 'internal', 'invalid', 'example', 'home', 'localdomain', 'ddev.site', 'lndo.site' );
+
 	/**
 	 * Loads the class, runs on init.
 	 *
@@ -60,6 +66,66 @@ class WC_Woo_Update_Manager_Plugin {
 		return WC_Helper_API::add_auth_parameters( $install_url );
 	}

+	/**
+	 * Whether the URL's host is one WooCommerce.com can't reach to push an install: private or reserved IPs,
+	 * single-label hosts and local development domains. Matches the check WooCommerce.com runs on its side.
+	 *
+	 * @internal
+	 * @since 11.4.0
+	 * @param string $url URL to check, usually the home URL.
+	 * @return bool
+	 */
+	public static function is_local_url( string $url ): bool {
+		$host = rtrim( strtolower( trim( (string) wp_parse_url( $url, PHP_URL_HOST ), '[]' ) ), '.' );
+
+		if ( '' === $host ) {
+			return false;
+		}
+
+		if ( false !== filter_var( $host, FILTER_VALIDATE_IP ) ) {
+			// The PHP range flags miss 100.64.0.0/10 (carrier-grade NAT) and changed in PHP 8.2, so rare ranges can be
+			// classified differently than on WooCommerce.com. A mismatch only falls back to the one-click install.
+			$long           = ip2long( $host );
+			$is_shared_ipv4 = false !== $long && $long >= ip2long( '100.64.0.0' ) && $long <= ip2long( '100.127.255.255' );
+
+			return $is_shared_ipv4 || false === filter_var( $host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE );
+		}
+
+		if ( ! str_contains( $host, '.' ) ) {
+			return true;
+		}
+
+		foreach ( self::LOCAL_DOMAINS as $domain ) {
+			if ( $host === $domain || str_ends_with( $host, '.' . $domain ) ) {
+				return true;
+			}
+		}
+
+		return false;
+	}
+
+	/**
+	 * Whether the Update Manager has to be installed by hand: the home URL is local and the helper talks to production
+	 * WooCommerce.com. Pointing `woocommerce_helper_api_base` or `woo_com_base_url` at another host keeps the one-click install.
+	 *
+	 * @internal
+	 * @since 11.4.0
+	 * @return bool
+	 */
+	public static function needs_manual_install(): bool {
+		if ( ! self::is_local_url( home_url() ) ) {
+			return false;
+		}
+
+		foreach ( array( WC_Helper_API::$api_base, WC_Helper::get_woocommerce_com_base_url() ) as $url ) {
+			if ( 'woocommerce.com' !== strtolower( (string) wp_parse_url( (string) $url, PHP_URL_HOST ) ) ) {
+				return false;
+			}
+		}
+
+		return true;
+	}
+
 	/**
 	 * Get the id of the Woo Update Manager plugin.
 	 *
diff --git a/plugins/woocommerce/includes/admin/helper/views/html-notice-woo-updater-not-installed.php b/plugins/woocommerce/includes/admin/helper/views/html-notice-woo-updater-not-installed.php
index 3cb91f956ac..60f90f1d6ca 100644
--- a/plugins/woocommerce/includes/admin/helper/views/html-notice-woo-updater-not-installed.php
+++ b/plugins/woocommerce/includes/admin/helper/views/html-notice-woo-updater-not-installed.php
@@ -11,17 +11,17 @@ defined( 'ABSPATH' ) || exit;
 	<a class="woocommerce-message-close notice-dismiss" href="<?php echo esc_url( wp_nonce_url( add_query_arg( 'wc-hide-notice', 'woo_updater_not_installed' ), 'woocommerce_hide_notices_nonce', '_wc_notice_nonce' ) ); ?>"><?php esc_html_e( 'Dismiss', 'woocommerce' ); ?></a>
 	<p>
 	<?php
-		echo wp_kses_post(
-			sprintf(
-				/* translators: 1: Woo Update Manager plugin install URL 2: Woo Update Manager plugin download URL */
-				__(
-					'Please <a href="%1$s">Install the WooCommerce.com Update Manager</a> to continue receiving the updates and streamlined support included in your WooCommerce.com subscriptions. Alternatively, you can <a href="%2$s">download</a> and install it manually.',
-					'woocommerce'
-				),
-				esc_url( WC_Woo_Update_Manager_Plugin::generate_install_url() ),
-				esc_url( WC_Woo_Update_Manager_Plugin::WOO_UPDATE_MANAGER_DOWNLOAD_URL )
-			)
-		);
-		?>
+	if ( WC_Woo_Update_Manager_Plugin::needs_manual_install() ) {
+		/* translators: 1: Woo Update Manager plugin download URL 2: Upload plugin screen URL */
+		$message = __( 'Please <a href="%1$s">download the WooCommerce.com Update Manager</a> and <a href="%2$s">upload it on the Add Plugins screen</a> to continue receiving the updates and streamlined support included in your WooCommerce.com subscriptions. WooCommerce.com can’t reach local sites, so it can’t install it for you.', 'woocommerce' );
+		$links   = array( WC_Woo_Update_Manager_Plugin::WOO_UPDATE_MANAGER_DOWNLOAD_URL, network_admin_url( 'plugin-install.php?tab=upload' ) );
+	} else {
+		/* translators: 1: Woo Update Manager plugin install URL 2: Woo Update Manager plugin download URL */
+		$message = __( 'Please <a href="%1$s">Install the WooCommerce.com Update Manager</a> to continue receiving the updates and streamlined support included in your WooCommerce.com subscriptions. Alternatively, you can <a href="%2$s">download</a> and install it manually.', 'woocommerce' );
+		$links   = array( WC_Woo_Update_Manager_Plugin::generate_install_url(), WC_Woo_Update_Manager_Plugin::WOO_UPDATE_MANAGER_DOWNLOAD_URL );
+	}
+
+	echo wp_kses_post( sprintf( $message, esc_url( $links[0] ), esc_url( $links[1] ) ) );
+	?>
 	</p>
 </div>
diff --git a/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-helper-updater-test.php b/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-helper-updater-test.php
index 4cc8a0e8e1e..08a3fdfad36 100644
--- a/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-helper-updater-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-helper-updater-test.php
@@ -2513,4 +2513,63 @@ class WC_Helper_Updater_Test extends WC_Unit_Test_Case {
 			)
 		);
 	}
+
+	/**
+	 * @testdox The plugin row asks for a download on a local store connected to production WooCommerce.com, and for a one-click install otherwise.
+	 *
+	 * @testWith ["http://localhost:8888", "https://woocommerce.com/wp-json/helper/1.0", "upload it to update"]
+	 *           ["http://localhost:8888", "https://woocommerce.test/wp-json/helper/1.0", "Install WooCommerce.com Update Manager"]
+	 *
+	 * @param string $home     Store home URL.
+	 * @param string $api_base Helper API base URL.
+	 * @param string $expected Text the plugin row should contain.
+	 */
+	public function test_plugin_row_message_offers_download_on_manual_install( string $home, string $api_base, string $expected ): void {
+		if ( WC_Woo_Update_Manager_Plugin::is_plugin_installed() ) {
+			$this->markTestSkipped( 'The Update Manager is installed in this environment.' );
+		}
+
+		$output = $this->with_store_urls(
+			$home,
+			$api_base,
+			static function () {
+				ob_start();
+				WC_Helper_Updater::add_install_marketplace_plugin_message( array(), (object) array( 'package' => '' ) );
+				return ob_get_clean();
+			}
+		);
+
+		$this->assertStringContainsString( $expected, $output );
+	}
+
+	/**
+	 * Run a callback with the given home URL and Helper API base.
+	 *
+	 * @param string   $home     Store home URL.
+	 * @param string   $api_base Helper API base URL.
+	 * @param callable $callback Code to run.
+	 * @return mixed The callback's return value.
+	 */
+	private function with_store_urls( string $home, string $api_base, callable $callback ) {
+		// wp-env defines WP_HOME, so the home option alone doesn't change home_url().
+		add_filter(
+			'home_url',
+			static function () use ( $home ) {
+				return $home;
+			}
+		);
+		$api_base_filter = static function () use ( $api_base ) {
+			return $api_base;
+		};
+		add_filter( 'woocommerce_helper_api_base', $api_base_filter );
+		WC_Helper_API::load();
+
+		try {
+			return $callback();
+		} finally {
+			// The static API base outlives the hook restore, so reload it without the test's filter.
+			remove_filter( 'woocommerce_helper_api_base', $api_base_filter );
+			WC_Helper_API::load();
+		}
+	}
 }
diff --git a/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-woo-update-manager-plugin-test.php b/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-woo-update-manager-plugin-test.php
new file mode 100644
index 00000000000..be35f45e19f
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-woo-update-manager-plugin-test.php
@@ -0,0 +1,53 @@
+<?php
+/**
+ * Unit tests for WC_Woo_Update_Manager_Plugin class
+ *
+ * @package WooCommerce\Tests\Admin\Helper
+ */
+
+declare(strict_types=1);
+
+/**
+ * Class WC_Woo_Update_Manager_Plugin_Test
+ */
+class WC_Woo_Update_Manager_Plugin_Test extends WC_Unit_Test_Case {
+	/**
+	 * @testdox Hosts WooCommerce.com can't reach count as local, public ones don't.
+	 *
+	 * @dataProvider urls
+	 *
+	 * @param string $url      URL to check.
+	 * @param bool   $is_local Expected result.
+	 */
+	public function test_is_local_url( string $url, bool $is_local ): void {
+		$this->assertSame( $is_local, WC_Woo_Update_Manager_Plugin::is_local_url( $url ), $url );
+	}
+
+	/**
+	 * URLs on both sides of the local-host check.
+	 *
+	 * @return array<string, array{string, bool}>
+	 */
+	public function urls(): array {
+		return array(
+			'localhost with port'      => array( 'http://localhost:8888', true ),
+			'loopback IPv4'            => array( 'http://127.0.0.1/shop', true ),
+			'private IPv4'             => array( 'https://192.168.1.20', true ),
+			'shared address IPv4'      => array( 'http://100.64.0.1', true ),
+			'above shared address'     => array( 'http://100.128.0.1', false ),
+			'loopback IPv6'            => array( 'http://[::1]:8080', true ),
+			'unique local IPv6'        => array( 'http://[fd12:3456::1]', true ),
+			'single-label host'        => array( 'http://mystore', true ),
+			'.local, any case'         => array( 'https://Shop.LOCAL', true ),
+			'.test'                    => array( 'https://store.test', true ),
+			'trailing root dot'        => array( 'http://localhost./shop', true ),
+			'DDEV subdomain'           => array( 'https://store.ddev.site', true ),
+			'.example'                 => array( 'https://store.example', true ),
+			'public domain'            => array( 'https://example.org', false ),
+			'local in a public domain' => array( 'https://local.example.com', false ),
+			'look-alike TLD'           => array( 'https://store.testing', false ),
+			'public IPv4'              => array( 'http://8.8.8.8', false ),
+			'no host'                  => array( '', false ),
+		);
+	}
+}