diff --git a/src/php/REST_API/Snippets/Snippets_REST_Controller.php b/src/php/REST_API/Snippets/Snippets_REST_Controller.php index 838abd83..cc81e2ca 100644 --- a/src/php/REST_API/Snippets/Snippets_REST_Controller.php +++ b/src/php/REST_API/Snippets/Snippets_REST_Controller.php @@ -12,6 +12,8 @@ use WP_REST_Response; use WP_REST_Server; use function Code_Snippets\activate_snippet; +use function Code_Snippets\clean_active_snippets_cache; +use function Code_Snippets\code_snippets; use function Code_Snippets\deactivate_snippet; use function Code_Snippets\delete_snippet; use function Code_Snippets\get_snippet; @@ -162,7 +164,7 @@ public function register_routes() { [ 'methods' => WP_REST_Server::EDITABLE, 'callback' => [ $this, 'activate_item' ], - 'permission_callback' => [ $this, 'update_item_permissions_check' ], + 'permission_callback' => [ $this, 'toggle_item_permissions_check' ], 'schema' => [ $this, 'get_item_schema' ], 'args' => $network_args, ] @@ -174,7 +176,7 @@ public function register_routes() { [ 'methods' => WP_REST_Server::EDITABLE, 'callback' => [ $this, 'deactivate_item' ], - 'permission_callback' => [ $this, 'update_item_permissions_check' ], + 'permission_callback' => [ $this, 'toggle_item_permissions_check' ], 'schema' => [ $this, 'get_item_schema' ], 'args' => $network_args, ] @@ -205,6 +207,165 @@ public function register_routes() { ); } + /** + * Determine whether a request targets network-scoped snippets. + * + * Only the literal boolean `true` (or its common string/integer equivalents) + * is treated as a network-scoped request. A missing or null `network` param + * means "site-scoped", and must not be escalated to the network capability. + * + * @param WP_REST_Request $request Full data about the request. + * + * @return bool + */ + private function is_network_scoped_request( $request ): bool { + if ( ! is_multisite() ) { + return false; + } + + if ( ! $request instanceof WP_REST_Request || ! $request->has_param( 'network' ) ) { + return false; + } + + $network = $request->get_param( 'network' ); + + if ( is_bool( $network ) ) { + return $network; + } + + if ( is_string( $network ) ) { + return in_array( strtolower( $network ), [ '1', 'true', 'yes' ], true ); + } + + return (bool) $network; + } + + /** + * Verify the current user has permission for the scope implied by the request. + * + * @param WP_REST_Request $request Full data about the request. + * + * @return bool + */ + private function check_request_capability( $request ): bool { + if ( $this->is_network_scoped_request( $request ) ) { + return code_snippets()->user_can_manage_network_snippets(); + } + + return code_snippets()->current_user_can(); + } + + /** + * Determine whether the request targets a shared network snippet. + * + * Shared network snippets are stored network-wide but each site decides whether + * to activate them via the per-site `active_shared_network_snippets` option. The + * `id` route parameter is used to look up the snippet so the result reflects the + * actual stored row rather than a value supplied in the request payload. + * + * @param WP_REST_Request $request Full data about the request. + * + * @return bool + */ + private function is_shared_network_snippet_request( $request ): bool { + if ( ! is_multisite() || ! $request instanceof WP_REST_Request ) { + return false; + } + + $snippet_id = absint( $request->get_param( 'id' ) ); + + if ( ! $snippet_id ) { + return false; + } + + $snippet = get_snippet( $snippet_id, true ); + + return $snippet && $snippet->id && $snippet->shared_network; + } + + /** + * Check if a given request has access to get items. + * + * @param WP_REST_Request $request Full data about the request. + * + * @return bool + */ + public function get_items_permissions_check( $request ): bool { + return $this->check_request_capability( $request ); + } + + /** + * Check if a given request has access to get a specific item. + * + * Shared network snippets are readable by any user who can manage snippets on + * the current site, since the snippet is intentionally exposed to subsites. + * + * @param WP_REST_Request $request Full data about the request. + * + * @return bool + */ + public function get_item_permissions_check( $request ): bool { + if ( $this->is_shared_network_snippet_request( $request ) ) { + return code_snippets()->current_user_can(); + } + + return $this->check_request_capability( $request ); + } + + /** + * Check if a given request has access to create items. + * + * @param WP_REST_Request $request Full data about the request. + * + * @return bool + */ + public function create_item_permissions_check( $request ): bool { + return $this->check_request_capability( $request ); + } + + /** + * Check if a given request has access to update a specific item. + * + * @param WP_REST_Request $request Full data about the request. + * + * @return bool + */ + public function update_item_permissions_check( $request ): bool { + return $this->check_request_capability( $request ); + } + + /** + * Check if a given request has access to delete a specific item. + * + * @param WP_REST_Request $request Full data about the request. + * + * @return bool + */ + public function delete_item_permissions_check( $request ): bool { + return $this->check_request_capability( $request ); + } + + /** + * Check if a given request has access to toggle a snippet's activation. + * + * For shared network snippets the activation toggle only writes to the + * per-site `active_shared_network_snippets` option, so the site capability + * is sufficient. For all other snippets we keep the strict capability check + * that prevents a subsite admin from forging `network=true` to operate on + * exclusive network-scoped snippets. + * + * @param WP_REST_Request $request Full data about the request. + * + * @return bool + */ + public function toggle_item_permissions_check( $request ): bool { + if ( $this->is_shared_network_snippet_request( $request ) ) { + return code_snippets()->current_user_can(); + } + + return $this->check_request_capability( $request ); + } + /** * Retrieves a collection of snippets, with pagination. * @@ -466,7 +627,23 @@ public function restore_item( WP_REST_Request $request ) { */ public function activate_item( WP_REST_Request $request ) { $item = $this->prepare_item_for_database( $request ); - $result = activate_snippet( $item->id, $item->network ); + $snippet = $item ? get_snippet( $item->id, $item->network ) : null; + + if ( ! $snippet || ! $snippet->id ) { + return new WP_Error( + 'rest_cannot_activate', + __( 'The snippet could not be found.', 'code-snippets' ), + [ 'status' => 404 ] + ); + } + + if ( $snippet->shared_network ) { + $this->set_shared_network_active( $snippet->id, true ); + $snippet->active = true; + return rest_ensure_response( $snippet ); + } + + $result = activate_snippet( $snippet->id, $snippet->network ); return $result instanceof Snippet ? rest_ensure_response( $result ) @@ -486,7 +663,23 @@ public function activate_item( WP_REST_Request $request ) { */ public function deactivate_item( WP_REST_Request $request ) { $item = $this->prepare_item_for_database( $request ); - $result = deactivate_snippet( $item->id, $item->network ); + $snippet = $item ? get_snippet( $item->id, $item->network ) : null; + + if ( ! $snippet || ! $snippet->id ) { + return new WP_Error( + 'rest_cannot_activate', + __( 'The snippet could not be found.', 'code-snippets' ), + [ 'status' => 404 ] + ); + } + + if ( $snippet->shared_network ) { + $this->set_shared_network_active( $snippet->id, false ); + $snippet->active = false; + return rest_ensure_response( $snippet ); + } + + $result = deactivate_snippet( $snippet->id, $snippet->network ); return $result instanceof Snippet ? rest_ensure_response( $result ) @@ -497,6 +690,35 @@ public function deactivate_item( WP_REST_Request $request ) { ); } + /** + * Toggle a shared network snippet's active state for the current site only. + * + * @param int $snippet_id Snippet identifier. + * @param bool $active Whether the snippet should be active on the current site. + * + * @return void + */ + private function set_shared_network_active( int $snippet_id, bool $active ): void { + $active_shared_snippets = get_option( 'active_shared_network_snippets', [] ); + + if ( ! is_array( $active_shared_snippets ) ) { + $active_shared_snippets = []; + } + + $already_active = in_array( $snippet_id, $active_shared_snippets, true ); + + if ( $active === $already_active ) { + return; + } + + $active_shared_snippets = $active + ? array_merge( $active_shared_snippets, [ $snippet_id ] ) + : array_values( array_diff( $active_shared_snippets, [ $snippet_id ] ) ); + + update_option( 'active_shared_network_snippets', $active_shared_snippets ); + clean_active_snippets_cache( code_snippets()->db->ms_table ); + } + /** * Prepare an instance of the Export class from a request. * diff --git a/tests/phpunit/test-rest-api-snippets-permissions.php b/tests/phpunit/test-rest-api-snippets-permissions.php new file mode 100644 index 00000000..b262759a --- /dev/null +++ b/tests/phpunit/test-rest-api-snippets-permissions.php @@ -0,0 +1,504 @@ +user->create( [ 'role' => 'administrator' ] ); + self::$subsite_admin_id = $factory->user->create( [ 'role' => 'administrator' ] ); + self::$editor_id = $factory->user->create( [ 'role' => 'editor' ] ); + + if ( is_multisite() ) { + grant_super_admin( self::$super_admin_id ); + } + } + + /** + * Set up before each test. + */ + public function set_up() { + parent::set_up(); + + wp_set_current_user( self::$super_admin_id ); + + $site_snippet = new Snippet( + [ + 'name' => 'Site Snippet Fixture', + 'desc' => 'Fixture snippet for permission tests.', + 'code' => "// site fixture\n", + 'scope' => 'global', + 'active' => false, + ] + ); + + $saved_site = save_snippet( $site_snippet ); + $this->assertInstanceOf( Snippet::class, $saved_site ); + $this->site_snippet_id = $saved_site->id; + + if ( is_multisite() ) { + $network_snippet = new Snippet( + [ + 'name' => 'Network Snippet Fixture', + 'desc' => 'Fixture snippet for permission tests (network).', + 'code' => "// network fixture\n", + 'scope' => 'global', + 'active' => false, + 'network' => true, + ] + ); + + $saved_network = save_snippet( $network_snippet ); + $this->assertInstanceOf( Snippet::class, $saved_network ); + $this->network_snippet_id = $saved_network->id; + } + } + + /** + * Dispatch a REST request and return the raw response object. + * + * @param string $method HTTP method. + * @param string $endpoint Endpoint path. + * @param array $params Request parameters. + * + * @return \WP_REST_Response + */ + protected function dispatch( string $method, string $endpoint, array $params = [] ) { + $request = new WP_REST_Request( $method, $endpoint ); + + foreach ( $params as $key => $value ) { + $request->set_param( $key, $value ); + } + + return rest_do_request( $request ); + } + + /** + * Test that an editor (no snippets cap) is blocked on every endpoint, regardless of network flag. + */ + public function test_editor_is_always_blocked() { + wp_set_current_user( self::$editor_id ); + + $response = $this->dispatch( 'GET', "/{$this->namespace}/{$this->base_route}" ); + $this->assert_forbidden_or_unauthorised( $response ); + + $response = $this->dispatch( 'GET', "/{$this->namespace}/{$this->base_route}", [ 'network' => true ] ); + $this->assert_forbidden_or_unauthorised( $response ); + } + + /** + * Test that the schema route remains publicly accessible. + */ + public function test_schema_route_is_public() { + wp_set_current_user( 0 ); + + $response = $this->dispatch( 'GET', "/{$this->namespace}/{$this->base_route}/schema" ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * Test that a site administrator can list site-scoped snippets. + */ + public function test_site_admin_can_list_site_snippets() { + wp_set_current_user( self::$subsite_admin_id ); + + $response = $this->dispatch( + 'GET', + "/{$this->namespace}/{$this->base_route}", + [ 'network' => false ] + ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * Test that an omitted `network` param defaults to site-scoped and is allowed. + */ + public function test_site_admin_can_list_with_omitted_network_param() { + wp_set_current_user( self::$subsite_admin_id ); + + $response = $this->dispatch( 'GET', "/{$this->namespace}/{$this->base_route}" ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * Test that a site administrator without the network cap is blocked when `network=true`. + * + * This is the core vulnerability: forging `network=true` in the payload must not + * escalate a subsite admin to network-scoped operations. + */ + public function test_site_admin_is_blocked_from_network_scoped_list() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Network scope only exists on multisite installs.' ); + } + + wp_set_current_user( self::$subsite_admin_id ); + + $response = $this->dispatch( + 'GET', + "/{$this->namespace}/{$this->base_route}", + [ 'network' => true ] + ); + + $this->assert_forbidden_or_unauthorised( $response ); + } + + /** + * Test that a site admin cannot read a specific network-scoped snippet via forged network=true. + */ + public function test_site_admin_is_blocked_from_network_scoped_get_item() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Network scope only exists on multisite installs.' ); + } + + wp_set_current_user( self::$subsite_admin_id ); + + $response = $this->dispatch( + 'GET', + "/{$this->namespace}/{$this->base_route}/{$this->network_snippet_id}", + [ 'network' => true ] + ); + + $this->assert_forbidden_or_unauthorised( $response ); + } + + /** + * Test that a site admin cannot create a network snippet via forged network=true. + */ + public function test_site_admin_is_blocked_from_creating_network_snippet() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Network scope only exists on multisite installs.' ); + } + + wp_set_current_user( self::$subsite_admin_id ); + + $response = $this->dispatch( + 'POST', + "/{$this->namespace}/{$this->base_route}", + [ + 'name' => 'Forged Network Snippet', + 'code' => "// forged\n", + 'scope' => 'global', + 'active' => false, + 'network' => true, + ] + ); + + $this->assert_forbidden_or_unauthorised( $response ); + } + + /** + * Test that a site admin cannot update a network snippet via forged network=true. + */ + public function test_site_admin_is_blocked_from_updating_network_snippet() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Network scope only exists on multisite installs.' ); + } + + wp_set_current_user( self::$subsite_admin_id ); + + $response = $this->dispatch( + 'POST', + "/{$this->namespace}/{$this->base_route}/{$this->network_snippet_id}", + [ + 'name' => 'Hijacked', + 'network' => true, + ] + ); + + $this->assert_forbidden_or_unauthorised( $response ); + } + + /** + * Test that a site admin cannot delete a network snippet via forged network=true. + */ + public function test_site_admin_is_blocked_from_deleting_network_snippet() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Network scope only exists on multisite installs.' ); + } + + wp_set_current_user( self::$subsite_admin_id ); + + $response = $this->dispatch( + 'DELETE', + "/{$this->namespace}/{$this->base_route}/{$this->network_snippet_id}", + [ 'network' => true ] + ); + + $this->assert_forbidden_or_unauthorised( $response ); + } + + /** + * Test that a site admin cannot activate a network snippet via forged network=true. + */ + public function test_site_admin_is_blocked_from_activating_network_snippet() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Network scope only exists on multisite installs.' ); + } + + wp_set_current_user( self::$subsite_admin_id ); + + $response = $this->dispatch( + 'POST', + "/{$this->namespace}/{$this->base_route}/{$this->network_snippet_id}/activate", + [ 'network' => true ] + ); + + $this->assert_forbidden_or_unauthorised( $response ); + } + + /** + * Test that a site admin cannot deactivate a network snippet via forged network=true. + */ + public function test_site_admin_is_blocked_from_deactivating_network_snippet() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Network scope only exists on multisite installs.' ); + } + + wp_set_current_user( self::$subsite_admin_id ); + + $response = $this->dispatch( + 'POST', + "/{$this->namespace}/{$this->base_route}/{$this->network_snippet_id}/deactivate", + [ 'network' => true ] + ); + + $this->assert_forbidden_or_unauthorised( $response ); + } + + /** + * Test that a site admin cannot export a network snippet via forged network=true. + */ + public function test_site_admin_is_blocked_from_exporting_network_snippet() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Network scope only exists on multisite installs.' ); + } + + wp_set_current_user( self::$subsite_admin_id ); + + $response = $this->dispatch( + 'GET', + "/{$this->namespace}/{$this->base_route}/{$this->network_snippet_id}/export", + [ 'network' => true ] + ); + + $this->assert_forbidden_or_unauthorised( $response ); + } + + /** + * Test that a site admin cannot restore a trashed network snippet via forged network=true. + */ + public function test_site_admin_is_blocked_from_restoring_network_snippet() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Network scope only exists on multisite installs.' ); + } + + wp_set_current_user( self::$subsite_admin_id ); + + $response = $this->dispatch( + 'POST', + "/{$this->namespace}/{$this->base_route}/{$this->network_snippet_id}/restore", + [ 'network' => true ] + ); + + $this->assert_forbidden_or_unauthorised( $response ); + } + + /** + * Test that stringly-typed truthy values also trigger the network capability check. + * + * @dataProvider provide_truthy_network_values + * + * @param mixed $value Payload value for `network`. + */ + public function test_forged_truthy_string_values_are_blocked( $value ) { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Network scope only exists on multisite installs.' ); + } + + wp_set_current_user( self::$subsite_admin_id ); + + $response = $this->dispatch( + 'GET', + "/{$this->namespace}/{$this->base_route}", + [ 'network' => $value ] + ); + + $this->assert_forbidden_or_unauthorised( + $response, + "Expected forged network={$value} to be blocked." + ); + } + + /** + * Data provider for truthy `network` variants a client might send. + * + * @return array + */ + public function provide_truthy_network_values(): array { + return [ + 'boolean true' => [ true ], + 'string "true"' => [ 'true' ], + 'string "1"' => [ '1' ], + 'integer 1' => [ 1 ], + 'string "yes"' => [ 'yes' ], + 'string "on"' => [ 'on' ], + 'string "anything"' => [ 'anything' ], + ]; + } + + /** + * Test that falsy network values remain site-scoped and are allowed for site admins. + * + * @dataProvider provide_falsy_network_values + * + * @param mixed $value Payload value for `network`. + */ + public function test_site_admin_allowed_for_falsy_network_values( $value ) { + wp_set_current_user( self::$subsite_admin_id ); + + $response = $this->dispatch( + 'GET', + "/{$this->namespace}/{$this->base_route}", + [ 'network' => $value ] + ); + + $this->assertSame( + 200, + $response->get_status(), + "Expected network={$value} to be treated as site-scoped and allowed." + ); + } + + /** + * Data provider for falsy `network` variants. + * + * @return array + */ + public function provide_falsy_network_values(): array { + return [ + 'boolean false' => [ false ], + 'string "false"' => [ 'false' ], + 'string "0"' => [ '0' ], + 'integer 0' => [ 0 ], + ]; + } + + /** + * Test that a super administrator with manage_network_options can perform network-scoped operations. + */ + public function test_super_admin_can_perform_network_scoped_operations() { + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Network scope only exists on multisite installs.' ); + } + + wp_set_current_user( self::$super_admin_id ); + + $response = $this->dispatch( + 'GET', + "/{$this->namespace}/{$this->base_route}", + [ 'network' => true ] + ); + + $this->assertSame( 200, $response->get_status() ); + + $response = $this->dispatch( + 'GET', + "/{$this->namespace}/{$this->base_route}/{$this->network_snippet_id}", + [ 'network' => true ] + ); + + $this->assertSame( 200, $response->get_status() ); + } + + /** + * Assert that a REST response indicates an auth failure. + * + * WordPress returns 401 if no user is logged in, otherwise 403. + * + * @param \WP_REST_Response $response Response under test. + * @param string $message Optional failure message. + */ + protected function assert_forbidden_or_unauthorised( $response, string $message = '' ) { + $status = $response->get_status(); + + $this->assertContains( + $status, + [ 401, 403 ], + $message + ? $message + : sprintf( 'Expected 401 or 403, got %d', $status ) + ); + } +} diff --git a/tests/phpunit/test-rest-api-snippets-shared-network-toggle.php b/tests/phpunit/test-rest-api-snippets-shared-network-toggle.php new file mode 100644 index 00000000..62676503 --- /dev/null +++ b/tests/phpunit/test-rest-api-snippets-shared-network-toggle.php @@ -0,0 +1,379 @@ +user->create( [ 'role' => 'administrator' ] ); + self::$subsite_admin_id = $factory->user->create( [ 'role' => 'administrator' ] ); + + if ( is_multisite() ) { + grant_super_admin( self::$super_admin_id ); + } + } + + /** + * Set up before each test. + */ + public function set_up() { + parent::set_up(); + + if ( ! is_multisite() ) { + $this->markTestSkipped( 'Shared network snippets only exist on multisite installs.' ); + } + + // Operate as a super admin while seeding so save_snippet() does not + // trip permission guards on the underlying table operations. + wp_set_current_user( self::$super_admin_id ); + + delete_option( 'active_shared_network_snippets' ); + delete_site_option( 'shared_network_snippets' ); + + $shared = save_snippet( + new Snippet( + [ + 'name' => 'Shared Network Snippet Fixture', + 'desc' => 'Stored in ms_snippets and exposed to subsites.', + 'code' => "// shared fixture\n", + 'scope' => 'global', + 'active' => false, + 'network' => true, + ] + ) + ); + + $this->assertInstanceOf( Snippet::class, $shared ); + $this->shared_snippet_id = $shared->id; + + // Mark the snippet as shared so subsites can opt in / out per-site. + update_site_option( 'shared_network_snippets', [ $this->shared_snippet_id ] ); + + $exclusive = save_snippet( + new Snippet( + [ + 'name' => 'Exclusive Network Snippet Fixture', + 'desc' => 'Network-wide snippet that subsite admins must not be able to toggle.', + 'code' => "// exclusive fixture\n", + 'scope' => 'global', + 'active' => false, + 'network' => true, + ] + ) + ); + + $this->assertInstanceOf( Snippet::class, $exclusive ); + $this->exclusive_network_snippet_id = $exclusive->id; + } + + /** + * Dispatch a REST request and return the raw response object. + * + * @param string $method HTTP method. + * @param string $endpoint Endpoint path. + * @param array $params Request parameters. + * + * @return \WP_REST_Response + */ + protected function dispatch( string $method, string $endpoint, array $params = [] ) { + $request = new WP_REST_Request( $method, $endpoint ); + + foreach ( $params as $key => $value ) { + $request->set_param( $key, $value ); + } + + return rest_do_request( $request ); + } + + /** + * Read the `active` column on `ms_snippets` directly. + * + * @param int $snippet_id Snippet identifier. + * + * @return string|null Stored value, or null if the row is missing. + */ + protected function read_ms_active_column( int $snippet_id ): ?string { + global $wpdb; + + $table = code_snippets()->db->ms_table; + $value = $wpdb->get_var( + $wpdb->prepare( "SELECT active FROM {$table} WHERE id = %d", $snippet_id ) + ); + + return null === $value ? null : (string) $value; + } + + /** + * Regression test: a subsite admin must be able to activate a shared network snippet. + * + * Previously the activate/deactivate endpoints inherited + * `update_item_permissions_check`, which required the network capability + * for any request carrying `network=true`. The new + * `toggle_item_permissions_check` recognises shared network snippets and + * accepts the site capability instead. + */ + public function test_subsite_admin_can_activate_shared_network_snippet() { + wp_set_current_user( self::$subsite_admin_id ); + + $response = $this->dispatch( + 'POST', + "/{$this->namespace}/{$this->base_route}/{$this->shared_snippet_id}/activate", + [ 'network' => true ] + ); + + $this->assertSame( + 200, + $response->get_status(), + 'Subsite admin should be able to activate a shared network snippet.' + ); + + $active_shared = get_option( 'active_shared_network_snippets', [] ); + $this->assertContains( + $this->shared_snippet_id, + $active_shared, + 'Activation should add the snippet ID to active_shared_network_snippets.' + ); + } + + /** + * Regression test for the original 403: deactivating a shared network snippet + * from a subsite must succeed for a subsite admin. + */ + public function test_subsite_admin_can_deactivate_shared_network_snippet() { + update_option( + 'active_shared_network_snippets', + [ $this->shared_snippet_id ] + ); + + wp_set_current_user( self::$subsite_admin_id ); + + $response = $this->dispatch( + 'POST', + "/{$this->namespace}/{$this->base_route}/{$this->shared_snippet_id}/deactivate", + [ 'network' => true ] + ); + + $this->assertSame( + 200, + $response->get_status(), + 'Subsite admin should be able to deactivate a shared network snippet.' + ); + + $active_shared = get_option( 'active_shared_network_snippets', [] ); + $this->assertNotContains( + $this->shared_snippet_id, + $active_shared, + 'Deactivation should remove the snippet ID from active_shared_network_snippets.' + ); + } + + /** + * Activating a shared network snippet must NOT flip the global + * `ms_snippets.active` column. Doing so would activate the snippet for + * every site in the network, which is the bug this test guards against. + */ + public function test_activating_shared_snippet_does_not_mutate_ms_snippets_active() { + $active_before = $this->read_ms_active_column( $this->shared_snippet_id ); + + wp_set_current_user( self::$subsite_admin_id ); + + $response = $this->dispatch( + 'POST', + "/{$this->namespace}/{$this->base_route}/{$this->shared_snippet_id}/activate", + [ 'network' => true ] + ); + + $this->assertSame( 200, $response->get_status() ); + + $active_after = $this->read_ms_active_column( $this->shared_snippet_id ); + + $this->assertSame( + $active_before, + $active_after, + 'Activating a shared snippet must not mutate ms_snippets.active.' + ); + } + + /** + * Companion guard: deactivation must not mutate `ms_snippets.active` either. + */ + public function test_deactivating_shared_snippet_does_not_mutate_ms_snippets_active() { + update_option( + 'active_shared_network_snippets', + [ $this->shared_snippet_id ] + ); + + $active_before = $this->read_ms_active_column( $this->shared_snippet_id ); + + wp_set_current_user( self::$subsite_admin_id ); + + $response = $this->dispatch( + 'POST', + "/{$this->namespace}/{$this->base_route}/{$this->shared_snippet_id}/deactivate", + [ 'network' => true ] + ); + + $this->assertSame( 200, $response->get_status() ); + + $active_after = $this->read_ms_active_column( $this->shared_snippet_id ); + + $this->assertSame( + $active_before, + $active_after, + 'Deactivating a shared snippet must not mutate ms_snippets.active.' + ); + } + + /** + * The privilege-escalation guard must remain in place for *exclusive* + * (non-shared) network snippets: a subsite admin cannot deactivate them + * even by sending `network=true`. + */ + public function test_subsite_admin_still_blocked_from_toggling_exclusive_network_snippet() { + wp_set_current_user( self::$subsite_admin_id ); + + $activate_response = $this->dispatch( + 'POST', + "/{$this->namespace}/{$this->base_route}/{$this->exclusive_network_snippet_id}/activate", + [ 'network' => true ] + ); + + $this->assertContains( + $activate_response->get_status(), + [ 401, 403 ], + 'Subsite admin must not be able to activate an exclusive network snippet.' + ); + + $deactivate_response = $this->dispatch( + 'POST', + "/{$this->namespace}/{$this->base_route}/{$this->exclusive_network_snippet_id}/deactivate", + [ 'network' => true ] + ); + + $this->assertContains( + $deactivate_response->get_status(), + [ 401, 403 ], + 'Subsite admin must not be able to deactivate an exclusive network snippet.' + ); + } + + /** + * A subsite admin should also be able to GET a shared network snippet, + * since the snippet is intentionally exposed to subsites and the React UI + * refetches it after a toggle. + */ + public function test_subsite_admin_can_read_shared_network_snippet() { + wp_set_current_user( self::$subsite_admin_id ); + + $response = $this->dispatch( + 'GET', + "/{$this->namespace}/{$this->base_route}/{$this->shared_snippet_id}", + [ 'network' => true ] + ); + + $this->assertSame( + 200, + $response->get_status(), + 'Subsite admin should be able to read a shared network snippet.' + ); + } + + /** + * Toggling a shared snippet on a subsite must only affect that site's + * `active_shared_network_snippets` option, not other sites'. + */ + public function test_activation_is_isolated_to_current_site() { + $other_site_id = self::factory()->blog->create(); + + wp_set_current_user( self::$subsite_admin_id ); + + $response = $this->dispatch( + 'POST', + "/{$this->namespace}/{$this->base_route}/{$this->shared_snippet_id}/activate", + [ 'network' => true ] + ); + + $this->assertSame( 200, $response->get_status() ); + + switch_to_blog( $other_site_id ); + $other_site_active = get_option( 'active_shared_network_snippets', [] ); + restore_current_blog(); + + $this->assertNotContains( + $this->shared_snippet_id, + $other_site_active, + 'Activation on the current site must not leak into other sites.' + ); + } +}