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 );
+ }
+ }
+}