diff --git a/tests/phpunit/tests/admin/wpPluginsListTableNetworkActionLinks.php b/tests/phpunit/tests/admin/wpPluginsListTableNetworkActionLinks.php new file mode 100644 index 0000000000000..fe3a14be888b8 --- /dev/null +++ b/tests/phpunit/tests/admin/wpPluginsListTableNetworkActionLinks.php @@ -0,0 +1,430 @@ +user->create(); + self::$admin_id = $factory->user->create( array( 'role' => 'administrator' ) ); + + grant_super_admin( self::$super_admin_id ); + } + + /** + * Revokes the super admin grant before the fixture users are deleted. + */ + public static function wpTearDownAfterClass() { + revoke_super_admin( self::$super_admin_id ); + } + + /** + * Sets up the network admin Plugins screen, the list table globals and the dependency state. + */ + public function set_up() { + parent::set_up(); + + foreach ( array( 'status', 'page', 's', 'totals', 'current_screen' ) as $name ) { + $this->original_globals[ $name ] = $GLOBALS[ $name ] ?? null; + } + + set_current_screen( 'plugins-network' ); + + $GLOBALS['status'] = 'all'; + $GLOBALS['page'] = 1; + $GLOBALS['s'] = ''; + $GLOBALS['totals'] = array(); + + $this->set_property_value( 'plugins', array() ); + $this->set_property_value( 'dependencies', array() ); + $this->set_property_value( 'dependency_slugs', array() ); + $this->set_property_value( 'dependency_filepaths', array() ); + $this->set_property_value( 'circular_dependencies_slugs', array() ); + $this->set_property_value( 'circular_dependencies_pairs', array() ); + $this->set_property_value( 'initialized', true ); + + update_option( 'active_plugins', array() ); + update_site_option( 'active_sitewide_plugins', array() ); + + $this->actions = null; + $this->slug = 'network-action-links-plugin-' . ++self::$counter; + + add_filter( 'network_admin_plugin_action_links', array( $this, 'capture_actions' ), PHP_INT_MAX ); + + wp_set_current_user( self::$super_admin_id ); + } + + /** + * Restores the globals and removes the test plugin. + */ + public function tear_down() { + foreach ( $this->original_globals as $name => $value ) { + $GLOBALS[ $name ] = $value; + } + + $this->remove_plugin(); + + parent::tear_down(); + } + + /** + * Tests the Network Deactivate link of a network-active plugin. + * + * @ticket 65819 + */ + public function test_should_link_network_deactivate_for_network_active_plugin() { + $this->network_activate(); + + $this->assertSame( + array( + 'deactivate' => 'Network Deactivate', + ), + $this->get_actions() + ); + } + + /** + * Tests that a network-active plugin that other active plugins require cannot be deactivated. + * + * @ticket 65819 + */ + public function test_should_not_link_network_deactivate_for_plugin_with_active_dependents() { + $this->set_dependent(); + $this->network_activate(); + + $this->assertSame( + array( + 'deactivate' => 'Network DeactivateYou cannot deactivate this plugin as other plugins require it.', + ), + $this->get_actions() + ); + } + + /** + * Tests the Network Activate and Delete links of a plugin that is not network active. + * + * @ticket 65819 + */ + public function test_should_link_network_activate_and_delete() { + $this->assertSame( + array( + 'activate' => 'Network Activate', + 'delete' => 'Delete', + ), + $this->get_actions() + ); + } + + /** + * Tests that a plugin that is active on the current site cannot be deleted. + * + * @ticket 65819 + */ + public function test_should_not_link_delete_for_plugin_active_on_site() { + update_option( 'active_plugins', array( $this->get_file() ) ); + + $this->assertSame( array( 'activate' ), array_keys( $this->get_actions() ) ); + } + + /** + * Tests that a plugin whose requirements are not met cannot be network activated. + * + * @ticket 65819 + * + * @dataProvider data_unmet_requirements + * + * @param array $plugin_data The plugin's requirement headers. + */ + public function test_should_not_network_activate_plugin_with_unmet_requirements( $plugin_data ) { + $this->assertSame( 'Cannot Activate', $this->get_actions( $plugin_data )['activate'] ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_unmet_requirements() { + return array( + 'PHP version' => array( array( 'RequiresPHP' => '99.0' ) ), + 'WordPress version' => array( array( 'RequiresWP' => '99.0' ) ), + ); + } + + /** + * Tests that a plugin with unmet dependencies cannot be network activated. + * + * @ticket 65819 + */ + public function test_should_not_network_activate_plugin_with_unmet_dependencies() { + $this->set_property_value( 'dependencies', array( $this->get_file() => array( 'missing' ) ) ); + $this->set_property_value( 'dependency_filepaths', array( 'missing' => false ) ); + + $this->assertSame( + 'Network ActivateYou cannot activate this plugin as it has unmet requirements.', + $this->get_actions()['activate'] + ); + } + + /** + * Tests that a plugin that other plugins require cannot be deleted, unless the dependency is circular. + * + * @ticket 65819 + * + * @dataProvider data_circular_dependencies + * + * @param bool $circular Whether the dependency is circular. + */ + public function test_delete_link_for_plugin_with_dependents( $circular ) { + $this->set_property_value( 'plugins', array( 'dependent/dependent.php' => array( 'Name' => 'Dependent' ) ) ); + $this->set_property_value( 'dependencies', array( 'dependent/dependent.php' => array( $this->slug ) ) ); + $this->set_property_value( 'dependency_slugs', array( $this->slug ) ); + + if ( $circular ) { + $this->set_property_value( 'circular_dependencies_slugs', array( $this->slug ) ); + } + + $this->assertSame( + $circular + ? 'Delete' + : 'DeleteYou cannot delete this plugin as other plugins require it.', + $this->get_actions()['delete'] + ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_circular_dependencies() { + return array( + 'regular dependency' => array( false ), + 'circular dependency' => array( true ), + ); + } + + /** + * Tests that a site administrator gets no action links on the network screen. + * + * @ticket 65819 + * + * @dataProvider data_network_active_states + * + * @param bool $network_active Whether the plugin is network active. + */ + public function test_should_not_add_links_for_user_without_network_capabilities( $network_active ) { + wp_set_current_user( self::$admin_id ); + + if ( $network_active ) { + $this->network_activate(); + } + + $this->assertSame( array(), $this->get_actions() ); + } + + /** + * Data provider. + * + * @return array[] + */ + public static function data_network_active_states() { + return array( + 'network-active plugin' => array( true ), + 'inactive plugin' => array( false ), + ); + } + + /** + * Tests that only the network filters receive the links, with the plugin file, data and context. + * + * @ticket 65819 + */ + public function test_should_pass_plugin_and_context_to_network_filters() { + $network_filter = new MockAction(); + $file_filter = new MockAction(); + $site_filter = new MockAction(); + add_filter( 'network_admin_plugin_action_links', array( $network_filter, 'filter' ), 10, 4 ); + add_filter( "network_admin_plugin_action_links_{$this->get_file()}", array( $file_filter, 'filter' ), 10, 4 ); + add_filter( 'plugin_action_links', array( $site_filter, 'filter' ) ); + + $this->get_actions(); + + $args = $network_filter->get_args(); + + $this->assertSame( $this->get_file(), $args[0][1], 'The filter should receive the plugin file.' ); + $this->assertSame( 'My Plugin', $args[0][2]['Name'], 'The filter should receive the plugin data.' ); + $this->assertSame( 'all', $args[0][3], 'The filter should receive the context.' ); + $this->assertSame( 1, $file_filter->get_call_count(), 'The plugin-specific network filter should run.' ); + $this->assertSame( 0, $site_filter->get_call_count(), 'The site filter should not run.' ); + } + + /** + * Captures the actions passed to the network_admin_plugin_action_links filter. + * + * @param array $actions The action links. + * @return array The unchanged action links. + */ + public function capture_actions( $actions ) { + $this->actions = $actions; + + return $actions; + } + + /** + * Renders the row of the test plugin and returns its action links. + * + * @param array $plugin_data The plugin data to add. + * @return array The action links. + */ + private function get_actions( $plugin_data = array() ) { + $dir = WP_PLUGIN_DIR . '/' . $this->slug; + $this->remove_plugin(); + $this->assertTrue( mkdir( $dir ), 'The test plugin directory should be created.' ); + $this->assertNotFalse( file_put_contents( $dir . '/' . $this->slug . '.php', " 'plugins-network' ) ); + + get_echo( + array( $table, 'single_row' ), + array( + array( + $this->get_file(), + array_merge( + array( + 'Name' => 'My Plugin', + 'slug' => $this->slug, + 'Description' => '', + 'Version' => '1.0', + 'Author' => '', + 'AuthorURI' => '', + 'PluginURI' => '', + ), + $plugin_data + ), + ), + ) + ); + + return $this->actions; + } + + /** + * Returns the plugin file of the test plugin. + * + * @return string The plugin file. + */ + private function get_file() { + return "{$this->slug}/{$this->slug}.php"; + } + + /** + * Returns the expected URL of an action. + * + * @param string $action The action. + * @param string $nonce The nonce action. + * @return string The escaped URL. + */ + private function get_url( $action, $nonce ) { + return "plugins.php?action={$action}&plugin={$this->slug}%2F{$this->slug}.php&plugin_status=all&paged=1&s&_wpnonce=" . wp_create_nonce( $nonce ); + } + + /** + * Returns the expected Delete URL. + * + * @return string The escaped URL. + */ + private function get_delete_url() { + return "plugins.php?action=delete-selected&checked%5B0%5D={$this->slug}%2F{$this->slug}.php&plugin_status=all&paged=1&s&_wpnonce=" . wp_create_nonce( 'bulk-plugins' ); + } + + /** + * Activates the test plugin network-wide. + */ + private function network_activate() { + $active = get_site_option( 'active_sitewide_plugins' ); + $active[ $this->get_file() ] = time(); + update_site_option( 'active_sitewide_plugins', $active ); + } + + /** + * Makes another network-active plugin require the test plugin. + */ + private function set_dependent() { + $this->set_property_value( 'plugins', array( 'dependent/dependent.php' => array( 'Name' => 'Dependent' ) ) ); + $this->set_property_value( 'dependencies', array( 'dependent/dependent.php' => array( $this->slug ) ) ); + $this->set_property_value( 'dependency_slugs', array( $this->slug ) ); + + update_site_option( 'active_sitewide_plugins', array( 'dependent/dependent.php' => time() ) ); + } + + /** + * Removes the test plugin, including one left over by an interrupted run. + */ + private function remove_plugin() { + $dir = WP_PLUGIN_DIR . '/' . $this->slug; + + if ( is_dir( $dir ) ) { + $this->rmdir( $dir ); + rmdir( $dir ); + } + } +}