From 6957c6317e95eed6a7e672388ab004b54e45a1f8 Mon Sep 17 00:00:00 2001 From: Brandon Kraft Date: Wed, 7 Oct 2026 13:35:24 -0500 Subject: [PATCH 1/6] Jetpack: Add a failing test for the security menu site matrix Each kind of site should have one security entry in the Jetpack menu, Protect, and no Settings > Security tab. Both tests fail until the Protect page work lands. --- .../settings-nav-tabs/test/component.jsx | 51 +++++ .../stuttgart-v1-jetpack-2876-resolution-plan | 5 + .../Jetpack_Security_Menu_Matrix_Test.php | 192 ++++++++++++++++++ 3 files changed, 248 insertions(+) create mode 100644 projects/plugins/jetpack/_inc/client/components/settings-nav-tabs/test/component.jsx create mode 100644 projects/plugins/jetpack/changelog/stuttgart-v1-jetpack-2876-resolution-plan create mode 100644 projects/plugins/jetpack/tests/php/general/Jetpack_Security_Menu_Matrix_Test.php diff --git a/projects/plugins/jetpack/_inc/client/components/settings-nav-tabs/test/component.jsx b/projects/plugins/jetpack/_inc/client/components/settings-nav-tabs/test/component.jsx new file mode 100644 index 000000000000..008777542323 --- /dev/null +++ b/projects/plugins/jetpack/_inc/client/components/settings-nav-tabs/test/component.jsx @@ -0,0 +1,51 @@ +import { MemoryRouter } from 'react-router'; +import { render, screen } from 'test/test-utils'; +import SettingsNavTabs from '../index'; + +jest.mock( 'components/data/query-site-plugins', () => ( { + __esModule: true, + default: () => null, +} ) ); + +jest.mock( 'components/search', () => ( { + __esModule: true, + default: () => null, +} ) ); + +const buildInitialState = () => ( { + jetpack: { + initialState: { + userData: { + currentUser: { + permissions: { manage_modules: true, publish_posts: true }, + }, + }, + }, + modules: { + items: { + 'account-protection': { activated: true }, + monitor: { activated: true }, + protect: { activated: true }, + sso: { activated: true }, + stats: { activated: true }, + waf: { activated: true }, + }, + }, + pluginsData: { items: {} }, + search: { searchTerm: '' }, + }, +} ); + +describe( 'SettingsNavTabs', () => { + it( 'has no Security tab on a site with every security module', () => { + render( + + + , + { initialState: buildInitialState() } + ); + + expect( screen.getByRole( 'tab', { name: 'Traffic' } ) ).toBeInTheDocument(); + expect( screen.queryByRole( 'tab', { name: 'Security' } ) ).not.toBeInTheDocument(); + } ); +} ); diff --git a/projects/plugins/jetpack/changelog/stuttgart-v1-jetpack-2876-resolution-plan b/projects/plugins/jetpack/changelog/stuttgart-v1-jetpack-2876-resolution-plan new file mode 100644 index 000000000000..a0fb7c77f952 --- /dev/null +++ b/projects/plugins/jetpack/changelog/stuttgart-v1-jetpack-2876-resolution-plan @@ -0,0 +1,5 @@ +Significance: patch +Type: other +Comment: Tests only. + + diff --git a/projects/plugins/jetpack/tests/php/general/Jetpack_Security_Menu_Matrix_Test.php b/projects/plugins/jetpack/tests/php/general/Jetpack_Security_Menu_Matrix_Test.php new file mode 100644 index 000000000000..3cf5a0aa31d9 --- /dev/null +++ b/projects/plugins/jetpack/tests/php/general/Jetpack_Security_Menu_Matrix_Test.php @@ -0,0 +1,192 @@ +user->create( array( 'role' => 'administrator' ) ); + wp_set_current_user( $user_id ); + + // Mock a connection. + Jetpack_Options::update_option( 'master_user', $user_id ); + Jetpack_Options::update_option( 'id', 1234 ); + Jetpack_Options::update_option( 'blog_token', 'asdasd.123123' ); + Jetpack_Options::update_option( 'user_tokens', array( $user_id => "honey.badger.$user_id" ) ); + + // The Protect page is being built behind this flag, which is removed when it ships. + add_filter( 'jetpack_feature_flag_enabled_jetpack-protect-dashboard', '__return_true' ); + } + + /** + * Tear down. + */ + public function tear_down() { + Jetpack_Options::delete_option( array( 'master_user', 'id', 'blog_token', 'user_tokens' ) ); + parent::tear_down(); + } + + /** + * The site matrix. + * + * @return array[] Site name to whether it has Scan, the standalone Protect plugin, and is Atomic. + */ + public static function provide_sites() { + return array( + 'self-hosted, no Scan' => array( false, false, false ), + 'self-hosted, with Scan' => array( true, false, false ), + 'self-hosted, with the Protect plugin' => array( true, true, false ), + 'Atomic' => array( true, false, true ), + ); + } + + /** + * The Jetpack menu has one security entry, Protect. + * + * @dataProvider provide_sites + * + * @param bool $has_scan Whether the site has Scan. + * @param bool $has_protect_plugin Whether the standalone Protect plugin is active. + * @param bool $is_atomic Whether the site is on WordPress.com Atomic. + */ + #[DataProvider( 'provide_sites' )] + public function test_jetpack_menu_has_one_security_entry( $has_scan, $has_protect_plugin, $is_atomic ) { + if ( $is_atomic ) { + $this->markTestSkipped( 'Waiting on the WordPress.com answer for what Atomic shows.' ); + } + + $this->set_scan_entitlement( $has_scan ); + + if ( $has_protect_plugin ) { + $this->activate_standalone_protect_plugin(); + } + + $this->assertSame( + array( array( 'Protect', 'jetpack-protect' ) ), + $this->security_entries( $this->render_jetpack_menu() ) + ); + } + + /** + * Gives the site Scan, or takes it away, under each check the plugin uses for it. + * + * @param bool $has_scan Whether the site has Scan. + */ + private function set_scan_entitlement( $has_scan ) { + $scan = new stdClass(); + $scan->state = $has_scan ? 'idle' : 'unavailable'; + $rewind = new stdClass(); + $rewind->state = 'unavailable'; + set_transient( 'jetpack_scan_state', $scan, WEEK_IN_SECONDS ); + set_transient( 'jetpack_rewind_state', $rewind, WEEK_IN_SECONDS ); + + update_option( + 'jetpack_active_plan', + array( + 'product_slug' => $has_scan ? 'jetpack_scan' : 'jetpack_free', + 'class' => $has_scan ? 'scan' : 'free', + 'features' => array( 'active' => $has_scan ? array( 'scan' ) : array() ), + ) + ); + } + + /** + * Stands in for the Jetpack Protect plugin, which this suite does not install. + * + * Mirrors what Jetpack_Protect::admin_page_init() registers, and the class Jetpack detects it by. + */ + private function activate_standalone_protect_plugin() { + class_alias( stdClass::class, 'Jetpack_Protect' ); + + add_action( + '_admin_menu', + static function () { + Admin_Menu::add_menu( + 'Jetpack Protect', + 'Protect', + 'manage_options', + 'jetpack-protect', + '__return_null', + null, + array( + 'product' => 'protect', + 'key' => 'jetpack-protect', + ) + ); + } + ); + } + + /** + * Loads the modules a connected site has without its owner turning anything on, then builds the menu. + * + * @return array[] The Jetpack submenu items, in the order WordPress rendered them. + */ + private function render_jetpack_menu() { + $default_modules = Jetpack::get_default_modules(); + add_filter( + 'jetpack_active_modules', + static function ( $modules ) use ( $default_modules ) { + return array_values( array_unique( array_merge( $modules, $default_modules ) ) ); + } + ); + Jetpack::load_modules(); + // Jetpack loads its admin on admin requests only, which a PHPUnit run is not. + require_once JETPACK__PLUGIN_DIR . 'class.jetpack-admin.php'; + + do_action( '_admin_menu' ); + do_action( 'admin_menu' ); + + global $submenu; + + return empty( $submenu['jetpack'] ) ? array() : array_values( $submenu['jetpack'] ); + } + + /** + * Picks the visible security entries out of the Jetpack submenu. + * + * @param array[] $items Jetpack submenu items. + * @return array[] A title and slug pair for each entry named Scan or Protect, or pointing at either. + */ + private function security_entries( array $items ) { + $entries = array(); + + foreach ( $items as $item ) { + $title = trim( wp_strip_all_tags( $item[0] ) ); + + // jetpack-mu-wpcom hides an entry by class and leaves it registered. + if ( isset( $item[4] ) && false !== strpos( $item[4], 'hide-if-js' ) ) { + continue; + } + + if ( preg_match( '/\b(Scan|Protect)\b/', $title ) || preg_match( '/scan|jetpack-protect/', $item[2] ) ) { + $entries[] = array( $title, $item[2] ); + } + } + + return $entries; + } +} From 3c995ba0352b738eca05a9a7f4af6755668efd43 Mon Sep 17 00:00:00 2001 From: Brandon Kraft Date: Wed, 7 Oct 2026 13:51:22 -0500 Subject: [PATCH 2/6] Tests: Turn the Protect page module on in the security menu matrix The module is not a default one, so the matrix covers sites whose owner has turned it on. --- .../php/general/Jetpack_Security_Menu_Matrix_Test.php | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/projects/plugins/jetpack/tests/php/general/Jetpack_Security_Menu_Matrix_Test.php b/projects/plugins/jetpack/tests/php/general/Jetpack_Security_Menu_Matrix_Test.php index 3cf5a0aa31d9..6a4771ca6bd9 100644 --- a/projects/plugins/jetpack/tests/php/general/Jetpack_Security_Menu_Matrix_Test.php +++ b/projects/plugins/jetpack/tests/php/general/Jetpack_Security_Menu_Matrix_Test.php @@ -9,7 +9,7 @@ use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses; /** - * Every kind of site gets exactly one security entry in the Jetpack menu, and it is Protect. + * With the Protect page turned on, every kind of site has one security entry in the Jetpack menu: Protect. * * Each row runs in its own process: the module loader includes a module file once, and the * standalone Protect plugin is detected by a class that cannot be undefined afterwards. @@ -141,16 +141,17 @@ static function () { } /** - * Loads the modules a connected site has without its owner turning anything on, then builds the menu. + * Loads a connected site's default modules plus the Protect page, then builds the menu. * * @return array[] The Jetpack submenu items, in the order WordPress rendered them. */ private function render_jetpack_menu() { - $default_modules = Jetpack::get_default_modules(); + // The Protect page is not a default module: the owner turns it on. + $active_modules = array_merge( Jetpack::get_default_modules(), array( 'protect-dashboard' ) ); add_filter( 'jetpack_active_modules', - static function ( $modules ) use ( $default_modules ) { - return array_values( array_unique( array_merge( $modules, $default_modules ) ) ); + static function ( $modules ) use ( $active_modules ) { + return array_values( array_unique( array_merge( $modules, $active_modules ) ) ); } ); Jetpack::load_modules(); From 349d45143a0416f97b6bd52c254e294fbae03a09 Mon Sep 17 00:00:00 2001 From: Brandon Kraft Date: Wed, 7 Oct 2026 15:07:24 -0500 Subject: [PATCH 3/6] Tests: Keep the security menu matrix to its intended failures Load only the Protect page on top of the site's modules, so a missing Forms build no longer fails the passing rows. Skip the self-hosted rows in the wpcomsh run, and alias a user-defined class for Phan. --- .../general/Jetpack_Security_Menu_Matrix_Test.php | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/projects/plugins/jetpack/tests/php/general/Jetpack_Security_Menu_Matrix_Test.php b/projects/plugins/jetpack/tests/php/general/Jetpack_Security_Menu_Matrix_Test.php index 6a4771ca6bd9..a807438eda1b 100644 --- a/projects/plugins/jetpack/tests/php/general/Jetpack_Security_Menu_Matrix_Test.php +++ b/projects/plugins/jetpack/tests/php/general/Jetpack_Security_Menu_Matrix_Test.php @@ -4,6 +4,7 @@ */ use Automattic\Jetpack\Admin_UI\Admin_Menu; +use Automattic\Jetpack\Status\Host; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\PreserveGlobalState; use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses; @@ -78,6 +79,10 @@ public function test_jetpack_menu_has_one_security_entry( $has_scan, $has_protec $this->markTestSkipped( 'Waiting on the WordPress.com answer for what Atomic shows.' ); } + if ( ( new Host() )->is_woa_site() ) { + $this->markTestSkipped( 'The self-hosted rows do not describe the wpcomsh run, which is Atomic.' ); + } + $this->set_scan_entitlement( $has_scan ); if ( $has_protect_plugin ) { @@ -119,7 +124,7 @@ private function set_scan_entitlement( $has_scan ) { * Mirrors what Jetpack_Protect::admin_page_init() registers, and the class Jetpack detects it by. */ private function activate_standalone_protect_plugin() { - class_alias( stdClass::class, 'Jetpack_Protect' ); + class_alias( get_class( new class() {} ), 'Jetpack_Protect' ); add_action( '_admin_menu', @@ -141,17 +146,16 @@ static function () { } /** - * Loads a connected site's default modules plus the Protect page, then builds the menu. + * Turns the Protect page on, loads the site's modules, then builds the menu. * * @return array[] The Jetpack submenu items, in the order WordPress rendered them. */ private function render_jetpack_menu() { // The Protect page is not a default module: the owner turns it on. - $active_modules = array_merge( Jetpack::get_default_modules(), array( 'protect-dashboard' ) ); add_filter( 'jetpack_active_modules', - static function ( $modules ) use ( $active_modules ) { - return array_values( array_unique( array_merge( $modules, $active_modules ) ) ); + static function ( $modules ) { + return array_values( array_unique( array_merge( $modules, array( 'protect-dashboard' ) ) ) ); } ); Jetpack::load_modules(); From 029b7949c22e6feac32930e49da8476df3e570dc Mon Sep 17 00:00:00 2001 From: Brandon Kraft Date: Wed, 7 Oct 2026 15:22:14 -0500 Subject: [PATCH 4/6] Tests: Drop a skip that never applies in the wpcomsh run That suite loads wpcomsh without the Atomic platform constants, so Jetpack treats the site as self-hosted and the rows still describe it. --- .../tests/php/general/Jetpack_Security_Menu_Matrix_Test.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/projects/plugins/jetpack/tests/php/general/Jetpack_Security_Menu_Matrix_Test.php b/projects/plugins/jetpack/tests/php/general/Jetpack_Security_Menu_Matrix_Test.php index a807438eda1b..edd8228818b3 100644 --- a/projects/plugins/jetpack/tests/php/general/Jetpack_Security_Menu_Matrix_Test.php +++ b/projects/plugins/jetpack/tests/php/general/Jetpack_Security_Menu_Matrix_Test.php @@ -4,7 +4,6 @@ */ use Automattic\Jetpack\Admin_UI\Admin_Menu; -use Automattic\Jetpack\Status\Host; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\PreserveGlobalState; use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses; @@ -79,10 +78,6 @@ public function test_jetpack_menu_has_one_security_entry( $has_scan, $has_protec $this->markTestSkipped( 'Waiting on the WordPress.com answer for what Atomic shows.' ); } - if ( ( new Host() )->is_woa_site() ) { - $this->markTestSkipped( 'The self-hosted rows do not describe the wpcomsh run, which is Atomic.' ); - } - $this->set_scan_entitlement( $has_scan ); if ( $has_protect_plugin ) { From 3eb4f50ffcaf00380990b21d2cf4b5449b0b147f Mon Sep 17 00:00:00 2001 From: Brandon Kraft Date: Tue, 13 Oct 2026 14:16:13 -0500 Subject: [PATCH 5/6] Tests: Expect no change on Atomic in the security menu matrix The first release keeps the Protect page off on Atomic, so the Atomic row stops being skipped and asserts what the site shows today: the Scan history link and no Protect entry. The Settings tab test gains an Atomic case, where the Security tab stays. --- .../settings-nav-tabs/test/component.jsx | 16 +++++++-- .../Jetpack_Security_Menu_Matrix_Test.php | 34 ++++++++++++++----- 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/projects/plugins/jetpack/_inc/client/components/settings-nav-tabs/test/component.jsx b/projects/plugins/jetpack/_inc/client/components/settings-nav-tabs/test/component.jsx index 008777542323..d666c8e95b7c 100644 --- a/projects/plugins/jetpack/_inc/client/components/settings-nav-tabs/test/component.jsx +++ b/projects/plugins/jetpack/_inc/client/components/settings-nav-tabs/test/component.jsx @@ -37,7 +37,17 @@ const buildInitialState = () => ( { } ); describe( 'SettingsNavTabs', () => { - it( 'has no Security tab on a site with every security module', () => { + afterEach( () => { + delete window.JetpackScriptData; + } ); + + // Atomic keeps the tab for the first release: the Protect page stays off there. + it.each( [ + [ 'has no Security tab on a self-hosted site', undefined, false ], + [ 'keeps the Security tab on an Atomic site', 'woa', true ], + ] )( '%s with every security module', ( _name, host, hasSecurityTab ) => { + window.JetpackScriptData = { site: { host } }; + render( @@ -46,6 +56,8 @@ describe( 'SettingsNavTabs', () => { ); expect( screen.getByRole( 'tab', { name: 'Traffic' } ) ).toBeInTheDocument(); - expect( screen.queryByRole( 'tab', { name: 'Security' } ) ).not.toBeInTheDocument(); + expect( screen.queryAllByRole( 'tab', { name: 'Security' } ) ).toHaveLength( + hasSecurityTab ? 1 : 0 + ); } ); } ); diff --git a/projects/plugins/jetpack/tests/php/general/Jetpack_Security_Menu_Matrix_Test.php b/projects/plugins/jetpack/tests/php/general/Jetpack_Security_Menu_Matrix_Test.php index edd8228818b3..ea44349b010c 100644 --- a/projects/plugins/jetpack/tests/php/general/Jetpack_Security_Menu_Matrix_Test.php +++ b/projects/plugins/jetpack/tests/php/general/Jetpack_Security_Menu_Matrix_Test.php @@ -4,12 +4,15 @@ */ use Automattic\Jetpack\Admin_UI\Admin_Menu; +use Automattic\Jetpack\Constants; +use Automattic\Jetpack\Redirect; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\PreserveGlobalState; use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses; /** - * With the Protect page turned on, every kind of site has one security entry in the Jetpack menu: Protect. + * With the Protect page turned on, a self-hosted site has one security entry in the Jetpack menu: Protect. + * Atomic keeps the entry it has today. * * Each row runs in its own process: the module loader includes a module file once, and the * standalone Protect plugin is detected by a class that cannot be undefined afterwards. @@ -46,6 +49,7 @@ public function set_up() { */ public function tear_down() { Jetpack_Options::delete_option( array( 'master_user', 'id', 'blog_token', 'user_tokens' ) ); + Constants::clear_constants(); parent::tear_down(); } @@ -64,7 +68,7 @@ public static function provide_sites() { } /** - * The Jetpack menu has one security entry, Protect. + * The Jetpack menu has one security entry: Protect, or on Atomic the Scan history link it already had. * * @dataProvider provide_sites * @@ -74,20 +78,32 @@ public static function provide_sites() { */ #[DataProvider( 'provide_sites' )] public function test_jetpack_menu_has_one_security_entry( $has_scan, $has_protect_plugin, $is_atomic ) { + $this->set_scan_entitlement( $has_scan ); + if ( $is_atomic ) { - $this->markTestSkipped( 'Waiting on the WordPress.com answer for what Atomic shows.' ); + $this->make_site_atomic(); } - $this->set_scan_entitlement( $has_scan ); - if ( $has_protect_plugin ) { $this->activate_standalone_protect_plugin(); } - $this->assertSame( - array( array( 'Protect', 'jetpack-protect' ) ), - $this->security_entries( $this->render_jetpack_menu() ) - ); + // The first release changes nothing on Atomic, where the Protect page stays off. + $expected = $is_atomic + ? array( array( 'Scan ↗', esc_url( Redirect::get_url( 'cloud-scan-history-wp-menu' ) ) ) ) + : array( array( 'Protect', 'jetpack-protect' ) ); + + $this->assertSame( $expected, $this->security_entries( $this->render_jetpack_menu() ) ); + } + + /** + * Makes the site a WordPress.com Atomic one that uses wp-admin, the way Host::is_woa_site() detects it. + */ + private function make_site_atomic() { + Constants::set_constant( 'ATOMIC_SITE_ID', 1 ); + Constants::set_constant( 'ATOMIC_CLIENT_ID', 1 ); + Constants::set_constant( 'WPCOMSH__PLUGIN_FILE', true ); + update_option( 'wpcom_admin_interface', 'wp-admin' ); } /** From c30811fe904224d61c95278b905b491825e9d605 Mon Sep 17 00:00:00 2001 From: Brandon Kraft Date: Wed, 14 Oct 2026 12:32:08 -0500 Subject: [PATCH 6/6] Clear the cached host check when making the test site Atomic Host caches is_woa_site(), and the plugin bootstrap already cached false, so the Atomic row never saw an Atomic site. --- .../tests/php/general/Jetpack_Security_Menu_Matrix_Test.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/projects/plugins/jetpack/tests/php/general/Jetpack_Security_Menu_Matrix_Test.php b/projects/plugins/jetpack/tests/php/general/Jetpack_Security_Menu_Matrix_Test.php index ea44349b010c..2af5a6e4613b 100644 --- a/projects/plugins/jetpack/tests/php/general/Jetpack_Security_Menu_Matrix_Test.php +++ b/projects/plugins/jetpack/tests/php/general/Jetpack_Security_Menu_Matrix_Test.php @@ -6,6 +6,7 @@ use Automattic\Jetpack\Admin_UI\Admin_Menu; use Automattic\Jetpack\Constants; use Automattic\Jetpack\Redirect; +use Automattic\Jetpack\Status\Cache; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\PreserveGlobalState; use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses; @@ -104,6 +105,8 @@ private function make_site_atomic() { Constants::set_constant( 'ATOMIC_CLIENT_ID', 1 ); Constants::set_constant( 'WPCOMSH__PLUGIN_FILE', true ); update_option( 'wpcom_admin_interface', 'wp-admin' ); + // Host caches is_woa_site(), and the plugin bootstrap already asked it. + Cache::clear(); } /**