diff --git a/.github/changelog/2210-from-description b/.github/changelog/2210-from-description new file mode 100644 index 000000000..7ea0a108f --- /dev/null +++ b/.github/changelog/2210-from-description @@ -0,0 +1,4 @@ +Significance: minor +Type: changed + +Improved recipient handling for clarity and improved visibility handling of activities. diff --git a/includes/functions.php b/includes/functions.php index 12fc9a4c4..e9e052fcb 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -525,40 +525,60 @@ function extract_recipients_from_activity( $data ) { $recipient_items = array(); foreach ( array( 'to', 'bto', 'cc', 'bcc', 'audience' ) as $i ) { - if ( array_key_exists( $i, $data ) ) { - if ( is_array( $data[ $i ] ) ) { - $recipient = $data[ $i ]; - } else { - $recipient = array( $data[ $i ] ); - } - $recipient_items = array_merge( $recipient_items, $recipient ); - } - - if ( is_array( $data['object'] ) && array_key_exists( $i, $data['object'] ) ) { - if ( is_array( $data['object'][ $i ] ) ) { - $recipient = $data['object'][ $i ]; - } else { - $recipient = array( $data['object'][ $i ] ); - } - $recipient_items = array_merge( $recipient_items, $recipient ); - } + $recipient_items = \array_merge( $recipient_items, extract_recipients_from_activity_property( $i, $data ) ); } + return \array_unique( $recipient_items ); +} + +/** + * Extract recipient URLs from a specific property of an Activity object. + * + * @param string $property The property to extract recipients from (e.g., 'to', 'cc'). + * @param array $data The Activity object as array. + * + * @return array The list of user URLs. + */ +function extract_recipients_from_activity_property( $property, $data ) { $recipients = array(); - // Flatten array. - foreach ( $recipient_items as $recipient ) { - if ( is_array( $recipient ) ) { - // Check if recipient is an object. - if ( array_key_exists( 'id', $recipient ) ) { - $recipients[] = $recipient['id']; - } - } else { - $recipients[] = $recipient; - } + if ( ! empty( $data[ $property ] ) ) { + $recipients = $data[ $property ]; + } elseif ( ! empty( $data['object'][ $property ] ) ) { + $recipients = $data['object'][ $property ]; + } + + $recipients = \array_map( '\Activitypub\object_to_uri', (array) $recipients ); + + return \array_unique( \array_filter( $recipients ) ); +} + +/** + * Determine the visibility of the activity based on its recipients. + * + * @param array $activity The activity data. + * + * @return string The visibility level: 'public', 'private', or 'direct'. + */ +function get_activity_visibility( $activity ) { + // Set default visibility for specific activity types. + if ( ! empty( $activity['type'] ) && in_array( $activity['type'], array( 'Accept', 'Delete', 'Follow', 'Reject', 'Undo' ), true ) ) { + return ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE; + } + + // Check 'to' field for public visibility. + $to = extract_recipients_from_activity_property( 'to', $activity ); + if ( ! empty( array_intersect( $to, ACTIVITYPUB_PUBLIC_AUDIENCE_IDENTIFIERS ) ) ) { + return ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC; + } + + // Check 'cc' field for quiet public visibility. + $cc = extract_recipients_from_activity_property( 'cc', $activity ); + if ( ! empty( array_intersect( $cc, ACTIVITYPUB_PUBLIC_AUDIENCE_IDENTIFIERS ) ) ) { + return ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC; } - return array_unique( $recipients ); + return ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE; } /** @@ -696,7 +716,7 @@ function url_to_commentid( $url ) { * * @param array|string $data The ActivityPub object. * - * @return string The URI of the ActivityPub object + * @return string The URI of the ActivityPub object. */ function object_to_uri( $data ) { // Check whether it is already simple. diff --git a/includes/handler/class-create.php b/includes/handler/class-create.php index 8886a857c..d2344ea19 100644 --- a/includes/handler/class-create.php +++ b/includes/handler/class-create.php @@ -9,7 +9,7 @@ use Activitypub\Collection\Interactions; -use function Activitypub\is_activity_public; +use function Activitypub\get_activity_visibility; use function Activitypub\is_activity_reply; use function Activitypub\is_self_ping; use function Activitypub\object_id_to_comment; @@ -22,19 +22,8 @@ class Create { * Initialize the class, registering WordPress hooks. */ public static function init() { - \add_action( - 'activitypub_inbox_create', - array( self::class, 'handle_create' ), - 10, - 3 - ); - - \add_filter( - 'activitypub_validate_object', - array( self::class, 'validate_object' ), - 10, - 3 - ); + \add_action( 'activitypub_inbox_create', array( self::class, 'handle_create' ), 10, 3 ); + \add_filter( 'activitypub_validate_object', array( self::class, 'validate_object' ), 10, 3 ); } /** @@ -47,7 +36,7 @@ public static function init() { public static function handle_create( $activity, $user_id, $activity_object = null ) { // Check if Activity is public or not. if ( - ! is_activity_public( $activity ) || + ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE === get_activity_visibility( $activity ) || ! is_activity_reply( $activity ) ) { return; diff --git a/includes/rest/class-inbox-controller.php b/includes/rest/class-inbox-controller.php index 3846d5d4c..e6c0fd688 100644 --- a/includes/rest/class-inbox-controller.php +++ b/includes/rest/class-inbox-controller.php @@ -149,23 +149,9 @@ public function create_item( $request ) { */ do_action( 'activitypub_rest_inbox_disallowed', $data, null, $type, $activity ); } else { - $recipients = extract_recipients_from_activity( $data ); - - foreach ( $recipients as $recipient ) { - if ( ! is_same_domain( $recipient ) ) { - continue; - } - - $user_id = Actors::get_id_by_various( $recipient ); - - if ( \is_wp_error( $user_id ) ) { - continue; - } - - if ( ! user_can_activitypub( $user_id ) ) { - continue; - } + $recipients = $this->get_local_recipients( $data ); + foreach ( $recipients as $user_id ) { // Check user-specific blocks for this recipient. if ( Moderation::activity_is_blocked_for_user( $activity, $user_id ) ) { /** @@ -288,4 +274,37 @@ public function get_item_schema() { return $this->add_additional_fields_schema( $this->schema ); } + + /** + * Extract recipients from the given Activity. + * + * @param array $activity The activity data. + * + * @return array An array of user IDs who are the recipients of the activity. + */ + private function get_local_recipients( $activity ) { + $recipients = extract_recipients_from_activity( $activity ); + $user_ids = array(); + + foreach ( $recipients as $recipient ) { + + if ( ! is_same_domain( $recipient ) ) { + continue; + } + + $user_id = Actors::get_id_by_resource( $recipient ); + + if ( \is_wp_error( $user_id ) ) { + continue; + } + + if ( ! user_can_activitypub( $user_id ) ) { + continue; + } + + $user_ids[] = $user_id; + } + + return $user_ids; + } } diff --git a/tests/includes/class-test-functions.php b/tests/includes/class-test-functions.php index d990ed604..96e37d6da 100644 --- a/tests/includes/class-test-functions.php +++ b/tests/includes/class-test-functions.php @@ -11,6 +11,9 @@ use Activitypub\Collection\Outbox; use function Activitypub\add_to_outbox; +use function Activitypub\extract_recipients_from_activity; +use function Activitypub\extract_recipients_from_activity_property; +use function Activitypub\get_activity_visibility; /** * Test class for Functions. @@ -954,7 +957,7 @@ public function public_activity_provider() { ), ), ), - true, + false, ), array( array( @@ -1032,4 +1035,329 @@ public function public_activity_provider() { ), ); } + + /** + * Data provider for testing extract_recipients_from_activity_property. + * + * @return array Test data sets. + */ + public function data_provider_extract_recipients() { + return array( + 'simple_string_recipient' => array( + 'data' => array( + 'to' => 'https://example.com/users/alice', + ), + 'attribute' => 'to', + 'expected' => array( 'https://example.com/users/alice' ), + ), + 'array_of_recipients' => array( + 'data' => array( + 'to' => array( + 'https://example.com/users/alice', + 'https://example.com/users/bob', + ), + ), + 'attribute' => 'to', + 'expected' => array( + 'https://example.com/users/alice', + 'https://example.com/users/bob', + ), + ), + 'object_recipients_with_id' => array( + 'data' => array( + 'cc' => array( + array( 'id' => 'https://example.com/users/charlie' ), + array( 'id' => 'https://example.com/users/diana' ), + ), + ), + 'attribute' => 'cc', + 'expected' => array( + 'https://example.com/users/charlie', + 'https://example.com/users/diana', + ), + ), + 'mixed_recipients' => array( + 'data' => array( + 'bcc' => array( + 'https://example.com/users/eve', + array( 'id' => 'https://example.com/users/frank' ), + ), + ), + 'attribute' => 'bcc', + 'expected' => array( + 'https://example.com/users/eve', + 'https://example.com/users/frank', + ), + ), + 'recipients_in_object' => array( + 'data' => array( + 'object' => array( + 'to' => 'https://example.com/users/grace', + ), + ), + 'attribute' => 'to', + 'expected' => array( 'https://example.com/users/grace' ), + ), + 'recipients_in_both_main_and_object' => array( + 'data' => array( + 'to' => 'https://example.com/users/henry', + 'object' => array( + 'to' => 'https://example.com/users/iris', + ), + ), + 'attribute' => 'to', + 'expected' => array( + 'https://example.com/users/henry', + ), + ), + 'duplicate_recipients' => array( + 'data' => array( + 'to' => array( + 'https://example.com/users/jack', + 'https://example.com/users/jack', // Duplicate. + ), + ), + 'attribute' => 'to', + 'expected' => array( 'https://example.com/users/jack' ), // Should be unique. + ), + 'no_recipients' => array( + 'data' => array( + 'cc' => array(), + ), + 'attribute' => 'to', // Different attribute. + 'expected' => array(), + ), + 'empty_data' => array( + 'data' => array(), + 'attribute' => 'to', + 'expected' => array(), + ), + 'object_with_id' => array( + 'data' => array( + 'to' => array( + array( + 'id' => 'https://example.com/users/kate', + 'type' => 'Person', + 'name' => 'Kate', + ), + ), + ), + 'attribute' => 'to', + 'expected' => array( + 'https://example.com/users/kate', + ), // Should be ignored. + ), + 'public_recipients' => array( + 'data' => array( + 'to' => array( + 'https://www.w3.org/ns/activitystreams#Public', + 'https://example.com/users/liam', + ), + ), + 'attribute' => 'to', + 'expected' => array( + 'https://www.w3.org/ns/activitystreams#Public', + 'https://example.com/users/liam', + ), + ), + 'audience_attribute' => array( + 'data' => array( + 'audience' => 'https://example.com/groups/followers', + ), + 'attribute' => 'audience', + 'expected' => array( 'https://example.com/groups/followers' ), + ), + ); + } + + /** + * Test extract_recipients_from_activity_property function. + * + * @dataProvider data_provider_extract_recipients + * + * @param array $data The activity data. + * @param string $attribute The attribute to extract. + * @param array $expected The expected recipients. + */ + public function test_extract_recipients_from_activity_property( $data, $attribute, $expected ) { + $actual = extract_recipients_from_activity_property( $attribute, $data ); + + // Sort both arrays to ensure order doesn't matter in comparison. + sort( $expected ); + sort( $actual ); + + $this->assertSame( $expected, $actual ); + } + + /** + * Test extract_recipients_from_activity_attribute function. + * + * @dataProvider data_provider_extract_recipients + * + * @param array $data The activity data. + * @param string $attribute The attribute to extract. + * @param array $expected The expected recipients. + */ + public function test_extract_recipients_from_activity( $data, $attribute, $expected ) { + $actual = extract_recipients_from_activity( $data ); + + // Sort both arrays to ensure order doesn't matter in comparison. + sort( $expected ); + sort( $actual ); + + $this->assertSame( $expected, $actual ); + } + + /** + * Test that the function returns unique recipients. + */ + public function test_unique_recipients() { + $data = array( + 'to' => array( + 'https://example.com/users/alice', + 'https://example.com/users/alice', // Duplicate. + ), + 'object' => array( + 'to' => 'https://example.com/users/alice', // Another duplicate. + ), + ); + $actual = extract_recipients_from_activity_property( 'to', $data ); + + $this->assertSame( array( 'https://example.com/users/alice' ), $actual ); + $this->assertCount( 1, $actual, 'Should return unique recipients only.' ); + } + + /** + * Test that the function returns unique recipients from extract_recipients_from_activity. + */ + public function test_unique_recipients_from_activity() { + $data = array( + 'to' => array( + 'https://example.com/users/alice', + 'https://example.com/users/alice', // Duplicate. + ), + 'object' => array( + 'to' => 'https://example.com/users/alice', // Another duplicate. + ), + ); + $actual = extract_recipients_from_activity( $data ); + $this->assertSame( array( 'https://example.com/users/alice' ), $actual ); + $this->assertCount( 1, $actual, 'Should return unique recipients only.' ); + } + + /** + * Data provider for visibility determination tests. + * + * @return array + */ + public function visibility_data_provider() { + return array( + // Public visibility - 'to' contains public identifier. + array( + 'activity' => array( + 'type' => 'Create', + 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), + 'cc' => array(), + ), + 'expected' => ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC, + 'description' => 'Public visibility via to field', + ), + // Quiet public visibility - 'cc' contains public identifier. + array( + 'activity' => array( + 'type' => 'Create', + 'to' => array( 'https://example.com/user/123' ), + 'cc' => array( 'https://www.w3.org/ns/activitystreams#Public' ), + ), + 'expected' => ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC, + 'description' => 'Quiet public visibility via cc field', + ), + // Private visibility - no public identifiers. + array( + 'activity' => array( + 'type' => 'Create', + 'to' => array( 'https://example.com/user/123' ), + 'cc' => array( 'https://example.com/user/456' ), + ), + 'expected' => ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE, + 'description' => 'Private visibility', + ), + // Special activity types always private - Accept. + array( + 'activity' => array( + 'type' => 'Accept', + 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), + 'cc' => array(), + ), + 'expected' => ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE, + 'description' => 'Accept activity always private', + ), + // Special activity types always private - Delete. + array( + 'activity' => array( + 'type' => 'Delete', + 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), + 'cc' => array(), + ), + 'expected' => ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE, + 'description' => 'Delete activity always private', + ), + // Special activity types always private - Follow. + array( + 'activity' => array( + 'type' => 'Follow', + 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), + 'cc' => array(), + ), + 'expected' => ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE, + 'description' => 'Follow activity always private', + ), + // Alternative public identifier - as:Public. + array( + 'activity' => array( + 'type' => 'Create', + 'to' => array( 'as:Public' ), + 'cc' => array(), + ), + 'expected' => ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC, + 'description' => 'Public visibility via as:Public identifier', + ), + // Empty activity. + array( + 'activity' => array( + 'type' => 'Create', + ), + 'expected' => ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE, + 'description' => 'Empty activity defaults to private', + ), + ); + } + + /** + * Test get_activity_visibility function. + * + * @dataProvider visibility_data_provider + * + * @param array $activity The activity data. + * @param string $expected Expected visibility level. + * @param string $description Test description. + */ + public function test_get_activity_visibility( $activity, $expected, $description ) { + $result = \Activitypub\get_activity_visibility( $activity ); + $this->assertSame( $expected, $result, $description ); + } + + /** + * Test get_activity_visibility with minimal activity data. + */ + public function test_get_activity_visibility_with_minimal_activity() { + $activity = array( + 'type' => 'Create', + 'to' => array( 'https://www.w3.org/ns/activitystreams#Public' ), + 'cc' => array(), + ); + + $result = \Activitypub\get_activity_visibility( $activity ); + $this->assertSame( ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC, $result, 'Should work with minimal activity data' ); + } } diff --git a/tests/includes/handler/class-test-create.php b/tests/includes/handler/class-test-create.php index b3ebd650b..817778821 100644 --- a/tests/includes/handler/class-test-create.php +++ b/tests/includes/handler/class-test-create.php @@ -98,6 +98,7 @@ public static function get_remote_metadata_by_actor( $value, $actor ) { public function create_test_object( $id = 'https://example.com/123' ) { return array( 'actor' => $this->user_url, + 'type' => 'Create', 'id' => 'https://example.com/id/' . microtime( true ), 'to' => array( $this->user_url ), 'cc' => array( 'https://www.w3.org/ns/activitystreams#Public' ), @@ -141,5 +142,142 @@ public function test_handle_create_public_accepted() { $this->assertInstanceOf( 'WP_Comment', $result[0] ); $this->assertEquals( 'example', $result[0]->comment_content ); + $this->assertCount( 1, $result ); + } + + /** + * Test handle create. + * + * @covers ::handle_create + */ + public function test_handle_create_public_accepted_without_type() { + $object = $this->create_test_object( 'https://example.com/123456' ); + unset( $object['type'] ); + + Create::handle_create( $object, $this->user_id ); + + $args = array( + 'type' => 'comment', + 'post_id' => $this->post_id, + ); + + $query = new \WP_Comment_Query( $args ); + $result = $query->comments; + + $this->assertInstanceOf( 'WP_Comment', $result[0] ); + $this->assertEquals( 'example', $result[0]->comment_content ); + } + + /** + * Test handle create check duplicate ID. + * + * @covers ::handle_create + */ + public function test_handle_create_check_duplicate_id() { + $id = 'https://example.com/id/' . microtime( true ); + $object = $this->create_test_object( $id ); + Create::handle_create( $object, $this->user_id ); + + $args = array( + 'type' => 'comment', + 'post_id' => $this->post_id, + ); + + $query = new \WP_Comment_Query( $args ); + $result = $query->comments; + + $this->assertInstanceOf( 'WP_Comment', $result[0] ); + $this->assertEquals( 'example', $result[0]->comment_content ); + $this->assertCount( 1, $result ); + + $object['object']['content'] = 'example2'; + Create::handle_create( $object, $this->user_id ); + + $args = array( + 'type' => 'comment', + 'post_id' => $this->post_id, + ); + + $query = new \WP_Comment_Query( $args ); + $result = $query->comments; + + $this->assertCount( 1, $result ); + } + + /** + * Test handle create check duplicate content. + * + * @covers ::handle_create + */ + public function test_handle_create_check_duplicate_content() { + $id = 'https://example.com/id/' . microtime( true ); + $object = $this->create_test_object( $id ); + Create::handle_create( $object, $this->user_id ); + + $args = array( + 'type' => 'comment', + 'post_id' => $this->post_id, + ); + + $query = new \WP_Comment_Query( $args ); + $result = $query->comments; + + $this->assertInstanceOf( 'WP_Comment', $result[0] ); + $this->assertEquals( 'example', $result[0]->comment_content ); + $this->assertCount( 1, $result ); + + $id = 'https://example.com/id/' . microtime( true ); + $object = $this->create_test_object( $id ); + Create::handle_create( $object, $this->user_id ); + + $args = array( + 'type' => 'comment', + 'post_id' => $this->post_id, + ); + + $query = new \WP_Comment_Query( $args ); + $result = $query->comments; + + $this->assertCount( 1, $result ); + } + + /** + * Test handle create multiple comments. + * + * @covers ::handle_create + */ + public function test_handle_create_check_multiple_comments() { + $id = 'https://example.com/id/4711'; + $object = $this->create_test_object( $id ); + Create::handle_create( $object, $this->user_id ); + + $args = array( + 'type' => 'comment', + 'post_id' => $this->post_id, + ); + + $query = new \WP_Comment_Query( $args ); + $result = $query->comments; + + $this->assertInstanceOf( 'WP_Comment', $result[0] ); + $this->assertEquals( 'example', $result[0]->comment_content ); + $this->assertCount( 1, $result ); + + $id = 'https://example.com/id/23'; + $object = $this->create_test_object( $id ); + $object['object']['content'] = 'example2'; + Create::handle_create( $object, $this->user_id ); + + $args = array( + 'type' => 'comment', + 'post_id' => $this->post_id, + ); + + $query = new \WP_Comment_Query( $args ); + $result = $query->comments; + + $this->assertInstanceOf( 'WP_Comment', $result[1] ); + $this->assertEquals( 'example2', $result[1]->comment_content ); + $this->assertCount( 2, $result ); } } diff --git a/tests/includes/rest/class-test-inbox-controller.php b/tests/includes/rest/class-test-inbox-controller.php index b1d98d607..bcfbfad1b 100644 --- a/tests/includes/rest/class-test-inbox-controller.php +++ b/tests/includes/rest/class-test-inbox-controller.php @@ -7,6 +7,8 @@ namespace Activitypub\Tests\Rest; +use Activitypub\Collection\Actors; + /** * Test class for Activitypub Rest Inbox. * @@ -21,6 +23,13 @@ class Test_Inbox_Controller extends \Activitypub\Tests\Test_REST_Controller_Test */ protected static $user_id; + /** + * Inbox Controller instance for testing. + * + * @var \Activitypub\Rest\Inbox_Controller + */ + private $inbox_controller; + /** * Create fake data before tests run. */ @@ -28,6 +37,15 @@ public static function set_up_before_class() { self::$user_id = self::factory()->user->create( array( 'role' => 'author' ) ); } + /** + * Set up the test. + */ + public function set_up() { + parent::set_up(); + + $this->inbox_controller = new \Activitypub\Rest\Inbox_Controller(); + } + /** * Delete fake data after tests run. */ @@ -434,4 +452,94 @@ public function test_create_item_with_invalid_request() { \remove_filter( 'activitypub_defer_signature_verification', '__return_true' ); } + + /** + * Test get_local_recipients method with no recipients. + * + * @covers ::get_local_recipients + */ + public function test_get_local_recipients_no_recipients() { + $activity = array( + 'type' => 'Create', + ); + + // Use reflection to test the private method. + $reflection = new \ReflectionClass( $this->inbox_controller ); + $method = $reflection->getMethod( 'get_local_recipients' ); + $method->setAccessible( true ); + + $result = $method->invoke( $this->inbox_controller, $activity ); + $this->assertEmpty( $result, 'Should return empty array when no recipients' ); + } + + /** + * Test get_local_recipients with external recipients only. + * + * @covers ::get_local_recipients + */ + public function test_get_local_recipients_external_only() { + $activity = array( + 'type' => 'Create', + 'to' => array( 'https://external.example.com/user/123' ), + 'cc' => array( 'https://another.example.com/user/456' ), + ); + + // Use reflection to test the private method. + $reflection = new \ReflectionClass( $this->inbox_controller ); + $method = $reflection->getMethod( 'get_local_recipients' ); + $method->setAccessible( true ); + + $result = $method->invoke( $this->inbox_controller, $activity ); + $this->assertEmpty( $result, 'Should return empty array for external recipients only' ); + } + + /** + * Test get_local_recipients with actual local actor. + * + * @covers ::get_local_recipients + */ + public function test_get_local_recipients_with_local_actor() { + // Get the actual actor ID for the user. + $actor = Actors::get_by_id( self::$user_id ); + $actor_id = $actor->get_id(); + + $activity = array( + 'type' => 'Create', + 'to' => array( $actor_id ), + 'cc' => array( 'https://external.example.com/user/123' ), + ); + + // Use reflection to test the private method. + $reflection = new \ReflectionClass( $this->inbox_controller ); + $method = $reflection->getMethod( 'get_local_recipients' ); + $method->setAccessible( true ); + + $result = $method->invoke( $this->inbox_controller, $activity ); + $this->assertContains( self::$user_id, $result, 'Should contain local user ID' ); + $this->assertCount( 1, $result, 'Should contain exactly one recipient' ); + } + + /** + * Test get_local_recipients handles malformed actor URLs. + * + * @covers ::get_local_recipients + */ + public function test_get_local_recipients_with_malformed_urls() { + $activity = array( + 'type' => 'Create', + 'to' => array( + 'not-a-valid-url', + get_home_url() . '/invalid-actor-path', + ), + 'cc' => array(), + ); + + // Use reflection to test the private method. + $reflection = new \ReflectionClass( $this->inbox_controller ); + $method = $reflection->getMethod( 'get_local_recipients' ); + $method->setAccessible( true ); + + $result = $method->invoke( $this->inbox_controller, $activity ); + $this->assertEmpty( $result, 'Should handle malformed URLs gracefully' ); + } }