From a6cf6d956d6c65d36b1c2e2f6c94dc4e9e573974 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Mon, 20 Jan 2025 10:38:19 -0600 Subject: [PATCH] Outbox: Update API Endpoint (#1170) * Update file name * Extend WP_REST_Controller * First pass at updated outbox endpoint * Remove expectation of having cc in the response * Update object type to be a bit more descriptive * First pass at create endpoint * Fix tests * Get Activity Type from meta after #1173 * Fix rest_url_path Props @pfefferle * Return accurate number of outbox items Props @pfefferle * Add more tests and remove unnecessary out-of-bounds error * Remove POST endpoint for now We currently don't have a use-case for it. * Limit Outbox by activity type and visibility * Account for posts without visibility meta Props @pfefferle * phpcs * Allow logged in users to see all activity types * Add request context to remaining hooks * Default query to limit activities Adds an exception for requests with privileges. * Slightly improve query --- activitypub.php | 4 +- includes/collection/class-outbox.php | 6 +- includes/rest/class-outbox-controller.php | 306 +++++++++++ includes/rest/class-outbox.php | 181 ------- includes/transformer/class-json.php | 11 + .../includes/collection/class-test-outbox.php | 4 +- .../rest/class-test-outbox-controller.php | 511 ++++++++++++++++++ 7 files changed, 837 insertions(+), 186 deletions(-) create mode 100644 includes/rest/class-outbox-controller.php delete mode 100644 includes/rest/class-outbox.php create mode 100644 tests/includes/rest/class-test-outbox-controller.php diff --git a/activitypub.php b/activitypub.php index 5960bff86..83e943a00 100644 --- a/activitypub.php +++ b/activitypub.php @@ -40,7 +40,6 @@ */ function rest_init() { Rest\Actors::init(); - Rest\Outbox::init(); Rest\Inbox::init(); Rest\Followers::init(); Rest\Following::init(); @@ -48,8 +47,9 @@ function rest_init() { Rest\Server::init(); Rest\Collection::init(); Rest\Post::init(); - ( new Rest\Interaction_Controller() )->register_routes(); ( new Rest\Application_Controller() )->register_routes(); + ( new Rest\Interaction_Controller() )->register_routes(); + ( new Rest\Outbox_Controller() )->register_routes(); ( new Rest\Webfinger_Controller() )->register_routes(); // Load NodeInfo endpoints only if blog is public. diff --git a/includes/collection/class-outbox.php b/includes/collection/class-outbox.php index f816af5f2..04392a92a 100644 --- a/includes/collection/class-outbox.php +++ b/includes/collection/class-outbox.php @@ -62,7 +62,11 @@ public static function add( $activity_object, $activity_type, $user_id, $content \kses_init_filters(); } - if ( ! $id || \is_wp_error( $id ) ) { + if ( \is_wp_error( $id ) ) { + return $id; + } + + if ( ! $id ) { return false; } diff --git a/includes/rest/class-outbox-controller.php b/includes/rest/class-outbox-controller.php new file mode 100644 index 000000000..b9aaf6355 --- /dev/null +++ b/includes/rest/class-outbox-controller.php @@ -0,0 +1,306 @@ +[\w\-\.]+)/outbox'; + + /** + * Register routes. + */ + public function register_routes() { + \register_rest_route( + $this->namespace, + '/' . $this->rest_base, + array( + 'args' => array( + 'user_id' => array( + 'description' => 'The ID of the user or actor.', + 'type' => 'string', + 'validate_callback' => array( $this, 'validate_user_id' ), + ), + ), + array( + 'methods' => \WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_items' ), + 'permission_callback' => array( 'Activitypub\Rest\Server', 'verify_signature' ), + 'args' => array( + 'page' => array( + 'description' => 'Current page of the collection.', + 'type' => 'integer', + 'default' => 1, + 'minimum' => 1, + ), + 'per_page' => array( + 'description' => 'Maximum number of items to be returned in result set.', + 'type' => 'integer', + 'default' => 10, + 'minimum' => 1, + 'maximum' => 100, + ), + ), + ), + 'schema' => array( $this, 'get_collection_schema' ), + ) + ); + } + + /** + * Validates the user_id parameter. + * + * @param mixed $user_id The user_id parameter. + * @return bool|\WP_Error True if the user_id is valid, WP_Error otherwise. + */ + public function validate_user_id( $user_id ) { + $user = Actors::get_by_various( $user_id ); + if ( \is_wp_error( $user ) ) { + return $user; + } + + return true; + } + + /** + * Retrieves a collection of outbox items. + * + * @param \WP_REST_Request $request Full details about the request. + * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure. + */ + public function get_items( $request ) { + $user_id = $request->get_param( 'user_id' ); + $page = $request->get_param( 'page' ); + $user = Actors::get_by_various( $user_id ); + + /** + * Action triggered prior to the ActivityPub profile being created and sent to the client. + * + * @param \WP_REST_Request $request The request object. + */ + \do_action( 'activitypub_rest_outbox_pre', $request ); + + /** + * Filters the list of activity types to include in the outbox. + * + * @param string[] $activity_types The list of activity types. + */ + $activity_types = apply_filters( 'rest_activitypub_outbox_activity_types', array( 'Announce', 'Create', 'Like', 'Update' ) ); + + $args = array( + 'posts_per_page' => $request->get_param( 'per_page' ), + 'author' => $user_id > 0 ? $user_id : null, + 'paged' => $page, + 'post_type' => Outbox::POST_TYPE, + 'post_status' => 'draft', + + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'meta_query' => array( + array( + 'key' => '_activitypub_activity_type', + 'value' => $activity_types, + 'compare' => 'IN', + ), + array( + 'relation' => 'OR', + array( + 'key' => 'activitypub_content_visibility', + 'compare' => 'NOT EXISTS', + ), + array( + 'key' => 'activitypub_content_visibility', + 'value' => ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC, + ), + ), + ), + ); + + if ( current_user_can( 'activitypub' ) ) { + unset( $args['meta_query'] ); + } + + /** + * Filters WP_Query arguments when querying Outbox items via the REST API. + * + * Enables adding extra arguments or setting defaults for an outbox collection request. + * + * @param array $args Array of arguments for WP_Query. + * @param \WP_REST_Request $request The REST API request. + */ + $args = apply_filters( 'rest_activitypub_outbox_query', $args, $request ); + + $outbox_query = new \WP_Query(); + $query_result = $outbox_query->query( $args ); + + $response = array( + '@context' => array( 'https://www.w3.org/ns/activitystreams' ), + 'id' => get_rest_url_by_path( sprintf( 'actors/%d/outbox', $user_id ) ), + 'generator' => 'https://wordpress.org/?v=' . \get_bloginfo( 'version' ), + 'actor' => $user->get_id(), + 'type' => 'OrderedCollectionPage', + 'partOf' => get_rest_url_by_path( sprintf( 'actors/%d/outbox', $user_id ) ), + 'totalItems' => $outbox_query->found_posts, + 'orderedItems' => array(), + ); + + update_postmeta_cache( \wp_list_pluck( $query_result, 'ID' ) ); + foreach ( $query_result as $outbox_item ) { + $response['orderedItems'][] = $this->prepare_item_for_response( $outbox_item, $request ); + } + + $max_pages = \ceil( $response['totalItems'] / $request->get_param( 'per_page' ) ); + $response['first'] = \add_query_arg( 'page', 1, $response['partOf'] ); + $response['last'] = \add_query_arg( 'page', $max_pages, $response['partOf'] ); + + if ( $max_pages > $page ) { + $response['next'] = \add_query_arg( 'page', $page + 1, $response['partOf'] ); + } + + if ( $page > 1 ) { + $response['prev'] = \add_query_arg( 'page', $page - 1, $response['partOf'] ); + } + + /** + * Filter the ActivityPub outbox array. + * + * @param array $response The ActivityPub outbox array. + * @param \WP_REST_Request $request The request object. + */ + $response = \apply_filters( 'activitypub_rest_outbox_array', $response, $request ); + + /** + * Action triggered after the ActivityPub profile has been created and sent to the client. + * + * @param \WP_REST_Request $request The request object. + */ + \do_action( 'activitypub_outbox_post', $request ); + + $response = \rest_ensure_response( $response ); + $response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) ); + + return $response; + } + + /** + * Prepares the item for the REST response. + * + * @param mixed $item WordPress representation of the item. + * @param \WP_REST_Request $request Request object. + * @return array Response object on success, or WP_Error object on failure. + */ + public function prepare_item_for_response( $item, $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + $type = \get_post_meta( $item->ID, '_activitypub_activity_type', true ); + $transformer = Factory::get_transformer( $item->post_content ); + $activity = $transformer->to_activity( $type ); + + return $activity->to_array( false ); + } + + /** + * Retrieves the outbox schema, conforming to JSON Schema. + * + * @return array Collection schema data. + */ + public function get_collection_schema() { + if ( $this->schema ) { + return $this->add_additional_fields_schema( $this->schema ); + } + + $schema = array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'outbox', + 'type' => 'object', + 'properties' => array( + '@context' => array( + 'description' => 'The JSON-LD context for the collection.', + 'type' => array( 'string', 'array', 'object' ), + 'required' => true, + ), + 'id' => array( + 'description' => 'The unique identifier for the collection.', + 'type' => 'string', + 'format' => 'uri', + 'required' => true, + ), + 'type' => array( + 'description' => 'The type of the collection.', + 'type' => 'string', + 'enum' => array( 'OrderedCollection', 'OrderedCollectionPage' ), + 'required' => true, + ), + 'actor' => array( + 'description' => 'The actor who owns this outbox.', + 'type' => 'string', + 'format' => 'uri', + 'required' => true, + ), + 'totalItems' => array( + 'description' => 'The total number of items in the collection.', + 'type' => 'integer', + 'minimum' => 0, + 'required' => true, + ), + 'orderedItems' => array( + 'description' => 'The items in the collection.', + 'type' => 'array', + 'items' => array( + 'type' => 'object', + ), + 'required' => true, + ), + 'first' => array( + 'description' => 'The first page of the collection.', + 'type' => 'string', + 'format' => 'uri', + ), + 'last' => array( + 'description' => 'The last page of the collection.', + 'type' => 'string', + 'format' => 'uri', + ), + 'next' => array( + 'description' => 'The next page of the collection.', + 'type' => 'string', + 'format' => 'uri', + ), + 'prev' => array( + 'description' => 'The previous page of the collection.', + 'type' => 'string', + 'format' => 'uri', + ), + ), + ); + + $this->schema = $schema; + + return $this->add_additional_fields_schema( $this->schema ); + } +} diff --git a/includes/rest/class-outbox.php b/includes/rest/class-outbox.php deleted file mode 100644 index 22183bd29..000000000 --- a/includes/rest/class-outbox.php +++ /dev/null @@ -1,181 +0,0 @@ -[\w\-\.]+)/outbox', - array( - array( - 'methods' => WP_REST_Server::READABLE, - 'callback' => array( self::class, 'user_outbox_get' ), - 'args' => self::request_parameters(), - 'permission_callback' => array( 'Activitypub\Rest\Server', 'verify_signature' ), - ), - ) - ); - } - - /** - * Renders the user-outbox - * - * @param \WP_REST_Request $request The request object. - * @return WP_REST_Response|\WP_Error The response object or WP_Error. - */ - public static function user_outbox_get( $request ) { - $user_id = $request->get_param( 'user_id' ); - $user = Actors::get_by_various( $user_id ); - - if ( is_wp_error( $user ) ) { - return $user; - } - - $post_types = \get_option( 'activitypub_support_post_types', array( 'post' ) ); - - $page = $request->get_param( 'page', 1 ); - - /** - * Action triggered prior to the ActivityPub profile being created and sent to the client. - */ - \do_action( 'activitypub_rest_outbox_pre' ); - - $json = new stdClass(); - - // phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase - $json->{'@context'} = get_context(); - $json->id = get_rest_url_by_path( sprintf( 'actors/%d/outbox', $user_id ) ); - $json->generator = 'http://wordpress.org/?v=' . get_masked_wp_version(); - $json->actor = $user->get_id(); - $json->type = 'OrderedCollectionPage'; - $json->partOf = get_rest_url_by_path( sprintf( 'actors/%d/outbox', $user_id ) ); - $json->totalItems = 0; - - if ( $user_id > 0 ) { - $count_posts = \count_user_posts( $user_id, $post_types, true ); - $json->totalItems = \intval( $count_posts ); - } else { - foreach ( $post_types as $post_type ) { - $count_posts = \wp_count_posts( $post_type ); - $json->totalItems += \intval( $count_posts->publish ); - } - } - - $json->first = \add_query_arg( 'page', 1, $json->partOf ); - $json->last = \add_query_arg( 'page', \ceil( $json->totalItems / 10 ), $json->partOf ); - - if ( $page && ( ( \ceil( $json->totalItems / 10 ) ) > $page ) ) { - $json->next = \add_query_arg( 'page', $page + 1, $json->partOf ); - } - - if ( $page && ( $page > 1 ) ) { - $json->prev = \add_query_arg( 'page', $page - 1, $json->partOf ); - } - // phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase - - if ( $page ) { - $posts = \get_posts( - array( - 'posts_per_page' => 10, - 'author' => $user_id > 0 ? $user_id : null, - 'paged' => $page, - 'post_type' => $post_types, - // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query - 'meta_query' => array( - 'relation' => 'OR', - array( - 'key' => 'activitypub_content_visibility', - 'compare' => 'NOT EXISTS', - ), - array( - 'key' => 'activitypub_content_visibility', - 'value' => ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL, - 'compare' => '!=', - ), - ), - ) - ); - - foreach ( $posts as $post ) { - $transformer = Factory::get_transformer( $post ); - - if ( \is_wp_error( $transformer ) ) { - continue; - } - - $post = $transformer->to_object(); - $activity = new Activity(); - $activity->set_type( 'Create' ); - $activity->set_object( $post ); - $json->orderedItems[] = $activity->to_array( false ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase - } - } - - /** - * Filter the ActivityPub outbox array. - * - * @param array $json The ActivityPub outbox array. - */ - $json = \apply_filters( 'activitypub_rest_outbox_array', $json ); - - /** - * Action triggered after the ActivityPub profile has been created and sent to the client - */ - \do_action( 'activitypub_outbox_post' ); - - $rest_response = new WP_REST_Response( $json, 200 ); - $rest_response->header( 'Content-Type', 'application/activity+json; charset=' . get_option( 'blog_charset' ) ); - - return $rest_response; - } - - /** - * The supported parameters. - * - * @return array List of parameters. - */ - public static function request_parameters() { - $params = array(); - - $params['page'] = array( - 'type' => 'integer', - 'default' => 1, - ); - - return $params; - } -} diff --git a/includes/transformer/class-json.php b/includes/transformer/class-json.php index 6b7c0288e..92974d5af 100644 --- a/includes/transformer/class-json.php +++ b/includes/transformer/class-json.php @@ -30,4 +30,15 @@ public function __construct( $item ) { parent::__construct( $object ); } + + /** + * Returns the public secondary audience of this object + * + * @see https://www.w3.org/TR/activitystreams-vocabulary/#dfn-cc + * + * @return array The secondary audience of this object. + */ + protected function get_cc() { + return $this->item->get( 'cc' ); + } } diff --git a/tests/includes/collection/class-test-outbox.php b/tests/includes/collection/class-test-outbox.php index 20e82c509..653300589 100644 --- a/tests/includes/collection/class-test-outbox.php +++ b/tests/includes/collection/class-test-outbox.php @@ -56,7 +56,7 @@ public function activity_object_provider() { ), 'Create', 1, - '{"@context":["https://www.w3.org/ns/activitystreams",{"Hashtag":"as:Hashtag","sensitive":"as:sensitive"}],"id":"https://example.com/1","type":"Note","content":"This is a note","contentMap":{"en":"This is a note"},"tag":[],"to":["https://www.w3.org/ns/activitystreams#Public"],"cc":[],"mediaType":"text/html","sensitive":false}', + '{"@context":["https://www.w3.org/ns/activitystreams",{"Hashtag":"as:Hashtag","sensitive":"as:sensitive"}],"id":"https://example.com/1","type":"Note","content":"This is a note","contentMap":{"en":"This is a note"},"tag":[],"to":["https://www.w3.org/ns/activitystreams#Public"],"mediaType":"text/html","sensitive":false}', ), array( array( @@ -67,7 +67,7 @@ public function activity_object_provider() { ), 'Create', 2, - '{"@context":["https://www.w3.org/ns/activitystreams",{"Hashtag":"as:Hashtag","sensitive":"as:sensitive"}],"id":"https://example.com/2","type":"Note","content":"This is another note","contentMap":{"en":"This is another note"},"tag":[],"to":["https://www.w3.org/ns/activitystreams#Public"],"cc":[],"mediaType":"text/html","sensitive":false}', + '{"@context":["https://www.w3.org/ns/activitystreams",{"Hashtag":"as:Hashtag","sensitive":"as:sensitive"}],"id":"https://example.com/2","type":"Note","content":"This is another note","contentMap":{"en":"This is another note"},"tag":[],"to":["https://www.w3.org/ns/activitystreams#Public"],"mediaType":"text/html","sensitive":false}', ), ); } diff --git a/tests/includes/rest/class-test-outbox-controller.php b/tests/includes/rest/class-test-outbox-controller.php new file mode 100644 index 000000000..4b2f29b54 --- /dev/null +++ b/tests/includes/rest/class-test-outbox-controller.php @@ -0,0 +1,511 @@ +post->create_many( 10 ); + } + + /** + * Clean up test fixtures. + */ + public static function wpTearDownAfterClass() { + foreach ( self::$post_ids as $post_id ) { + \wp_delete_post( $post_id, true ); + } + + \remove_filter( 'activitypub_defer_signature_verification', '__return_true' ); + } + + /** + * Set up test environment. + */ + public function set_up() { + parent::set_up(); + \add_filter( 'activitypub_defer_signature_verification', '__return_true' ); + } + + /** + * Test route registration. + * + * @covers ::register_routes + */ + public function test_register_routes() { + $routes = rest_get_server()->get_routes(); + $this->assertArrayHasKey( '/' . ACTIVITYPUB_REST_NAMESPACE . '/(?:users|actors)/(?P[\w\-\.]+)/outbox', $routes ); + } + + /** + * Test user ID validation. + * + * @covers ::validate_user_id + */ + public function test_validate_user_id() { + $controller = new Outbox_Controller(); + $this->assertTrue( $controller->validate_user_id( 0 ) ); + $this->assertTrue( $controller->validate_user_id( '1' ) ); + $this->assertWPError( $controller->validate_user_id( 'user-1' ) ); + } + + /** + * Test getting items. + * + * @covers ::get_items + */ + public function test_get_items() { + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/outbox' ); + $response = \rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + } + + /** + * Test schema. + * + * @covers ::get_collection_schema + */ + public function test_get_collection_schema() { + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/outbox' ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $schema = ( new Outbox_Controller() )->get_collection_schema(); + + $valid = \rest_validate_value_from_schema( $data, $schema ); + $this->assertNotWPError( $valid, 'Response failed schema validation: ' . ( \is_wp_error( $valid ) ? $valid->get_error_message() : '' ) ); + } + + /** + * Test getting items with pagination. + * + * @covers ::get_items + */ + public function test_get_items_pagination() { + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/outbox' ); + $request->set_param( 'page', 2 ); + $request->set_param( 'per_page', 3 ); + $response = \rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertArrayHasKey( 'prev', $data ); + $this->assertArrayHasKey( 'next', $data ); + $this->assertStringContainsString( 'page=1', $data['prev'] ); + $this->assertStringContainsString( 'page=3', $data['next'] ); + } + + /** + * Test getting items response structure. + * + * @covers ::get_items + */ + public function test_get_items_response_structure() { + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/outbox' ); + $response = \rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertArrayHasKey( '@context', $data ); + $this->assertArrayHasKey( 'id', $data ); + $this->assertArrayHasKey( 'type', $data ); + $this->assertArrayHasKey( 'totalItems', $data ); + $this->assertArrayHasKey( 'orderedItems', $data ); + $this->assertEquals( 'OrderedCollectionPage', $data['type'] ); + $this->assertIsArray( $data['orderedItems'] ); + + $headers = $response->get_headers(); + $this->assertEquals( 'application/activity+json; charset=' . \get_option( 'blog_charset' ), $headers['Content-Type'] ); + } + + /** + * Test getting items for specific user. + * + * @covers ::get_items + */ + public function test_get_items_specific_user() { + $user_id = self::factory()->user->create( array( 'role' => 'author' ) ); + $post_id = self::factory()->post->create( + array( + 'post_author' => $user_id, + 'post_type' => 'ap_outbox', + 'post_status' => 'draft', + 'post_title' => 'https://example.org/activity/1', + 'post_content' => wp_json_encode( + array( + '@context' => array( 'https://www.w3.org/ns/activitystreams' ), + 'id' => 'https://example.org/activity/1', + 'type' => 'Create', + 'actor' => 'https://example.org/user/' . $user_id, + 'object' => array( + 'id' => 'https://example.org/note/1', + 'type' => 'Note', + 'content' => 'Test content', + ), + ) + ), + 'meta_input' => array( + '_activitypub_activity_type' => 'Create', + '_activitypub_activity_actor' => 'user', + 'activitypub_content_visibility' => ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC, + ), + ) + ); + + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/' . $user_id . '/outbox' ); + $response = \rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertSame( 1, (int) $data['totalItems'] ); + $this->assertStringContainsString( (string) $user_id, $data['actor'] ); + + \wp_delete_post( $post_id, true ); + \wp_delete_user( $user_id ); + } + + /** + * Test outbox filters. + * + * @covers ::get_items + */ + public function test_get_items_filters() { + $filter_called = false; + $pre_called = false; + $post_called = false; + + \add_filter( + 'activitypub_rest_outbox_array', + function ( $response ) use ( &$filter_called ) { + $filter_called = true; + return $response; + } + ); + + \add_action( + 'activitypub_rest_outbox_pre', + function () use ( &$pre_called ) { + $pre_called = true; + } + ); + + \add_action( + 'activitypub_outbox_post', + function () use ( &$post_called ) { + $post_called = true; + } + ); + + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/outbox' ); + \rest_get_server()->dispatch( $request ); + + $this->assertTrue( $filter_called, 'activitypub_rest_outbox_array filter was not called.' ); + $this->assertTrue( $pre_called, 'activitypub_rest_outbox_pre action was not called.' ); + $this->assertTrue( $post_called, 'activitypub_outbox_post action was not called.' ); + + \remove_all_filters( 'activitypub_rest_outbox_array' ); + \remove_all_actions( 'activitypub_rest_outbox_pre' ); + \remove_all_actions( 'activitypub_outbox_post' ); + } + + /** + * Test getting items with minimum per_page. + * + * @covers ::get_items + */ + public function test_get_items_minimum_per_page() { + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/outbox' ); + $request->set_param( 'per_page', 1 ); + $response = \rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertCount( 1, $data['orderedItems'] ); + } + + /** + * Test getting items with maximum per_page. + * + * @covers ::get_items + */ + public function test_get_items_maximum_per_page() { + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/0/outbox' ); + $request->set_param( 'per_page', 100 ); + $response = \rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + } + + /** + * Data provider for test_get_items_activity_type. + * + * @return array[] Test parameters. + */ + public function data_activity_types() { + return array( + 'create_activity' => array( + 'type' => 'Create', + 'object' => array( + 'id' => 'https://example.org/note/1', + 'type' => 'Note', + 'content' => 'Test content', + ), + 'allowed' => true, + ), + 'announce_activity' => array( + 'type' => 'Announce', + 'object' => 'https://example.org/note/2', + 'allowed' => true, + ), + 'like_activity' => array( + 'type' => 'Like', + 'object' => 'https://example.org/note/3', + 'allowed' => true, + ), + 'update_activity' => array( + 'type' => 'Update', + 'object' => array( + 'id' => 'https://example.org/note/4', + 'type' => 'Note', + 'content' => 'Updated content', + ), + 'allowed' => true, + ), + 'delete_activity' => array( + 'type' => 'Delete', + 'object' => 'https://example.org/note/5', + 'allowed' => false, + ), + 'follow_activity' => array( + 'type' => 'Follow', + 'object' => 'https://example.org/user/6', + 'allowed' => false, + ), + ); + } + + /** + * Test getting items with different activity types. + * + * @covers ::get_items + * @dataProvider data_activity_types + * + * @param string $type Activity type. + * @param string|array $activity Activity object. + * @param bool $allowed Whether the activity type is allowed for public users. + */ + public function test_get_items_activity_type( $type, $activity, $allowed ) { + $user_id = self::factory()->user->create( array( 'role' => 'author' ) ); + $post_id = self::factory()->post->create( + array( + 'post_author' => $user_id, + 'post_type' => Outbox::POST_TYPE, + 'post_status' => 'draft', + 'post_title' => "https://example.org/activity/{$type}", + 'post_content' => \wp_json_encode( + array( + '@context' => array( 'https://www.w3.org/ns/activitystreams' ), + 'id' => "https://example.org/activity/{$type}", + 'type' => $type, + 'actor' => 'https://example.org/user/' . $user_id, + 'object' => $activity, + ) + ), + 'meta_input' => array( + '_activitypub_activity_type' => $type, + '_activitypub_activity_actor' => 'user', + 'activitypub_content_visibility' => \ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC, + ), + ) + ); + + // Test as logged-out user. + \wp_set_current_user( 0 ); + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/' . $user_id . '/outbox' ); + $response = \rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $activity_types = \wp_list_pluck( $data['orderedItems'], 'type' ); + + if ( $allowed ) { + $this->assertContains( $type, $activity_types, sprintf( 'Activity type "%s" should be visible to logged-out users.', $type ) ); + $this->assertSame( 1, (int) $data['totalItems'], sprintf( 'Activity type "%s" should be included in total items for logged-out users.', $type ) ); + } else { + $this->assertNotContains( $type, $activity_types, sprintf( 'Activity type "%s" should not be visible to logged-out users.', $type ) ); + $this->assertSame( 0, (int) $data['totalItems'], sprintf( 'Activity type "%s" should not be included in total items for logged-out users.', $type ) ); + } + + // Test as logged-in user with activitypub capability. + \wp_set_current_user( $user_id ); + $this->assertTrue( \current_user_can( 'activitypub' ) ); + + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/' . $user_id . '/outbox' ); + $response = \rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $activity_types = \wp_list_pluck( $data['orderedItems'], 'type' ); + + $this->assertContains( $type, $activity_types, sprintf( 'Activity type "%s" should be visible to users with activitypub capability.', $type ) ); + $this->assertSame( 1, (int) $data['totalItems'], sprintf( 'Activity type "%s" should be included in total items for users with activitypub capability.', $type ) ); + + \wp_delete_post( $post_id, true ); + \wp_delete_user( $user_id ); + } + + /** + * Data provider for test_get_items_content_visibility. + * + * @return array[] Test parameters. + */ + public function data_content_visibility() { + return array( + 'no_visibility' => array( + 'visibility' => null, + 'public_visible' => true, + 'private_visible' => true, + ), + 'public' => array( + 'visibility' => \ACTIVITYPUB_CONTENT_VISIBILITY_PUBLIC, + 'public_visible' => true, + 'private_visible' => true, + ), + 'quiet_public' => array( + 'visibility' => \ACTIVITYPUB_CONTENT_VISIBILITY_QUIET_PUBLIC, + 'public_visible' => false, + 'private_visible' => true, + ), + 'local' => array( + 'visibility' => \ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL, + 'public_visible' => false, + 'private_visible' => true, + ), + ); + } + + /** + * Test content visibility for logged-in and logged-out users. + * + * @covers ::get_items + * @dataProvider data_content_visibility + * + * @param string|null $visibility Content visibility setting. + * @param bool $public_visible Whether content should be visible to public users. + * @param bool $private_visible Whether content should be visible to users with activitypub capability. + */ + public function test_get_items_content_visibility( $visibility, $public_visible, $private_visible ) { + $user_id = self::factory()->user->create( array( 'role' => 'author' ) ); + $meta_input = array( + '_activitypub_activity_type' => 'Create', + '_activitypub_activity_actor' => 'user', + ); + + if ( null !== $visibility ) { + $meta_input['activitypub_content_visibility'] = $visibility; + } + + $post_id = self::factory()->post->create( + array( + 'post_author' => $user_id, + 'post_type' => Outbox::POST_TYPE, + 'post_status' => 'draft', + 'post_title' => 'https://example.org/activity/1', + 'post_content' => \wp_json_encode( + array( + '@context' => array( 'https://www.w3.org/ns/activitystreams' ), + 'id' => 'https://example.org/activity/1', + 'type' => 'Create', + 'actor' => 'https://example.org/user/' . $user_id, + 'object' => array( + 'id' => 'https://example.org/note/1', + 'type' => 'Note', + 'content' => 'Test content', + ), + ) + ), + 'meta_input' => $meta_input, + ) + ); + + // Test as logged-out user. + \wp_set_current_user( 0 ); + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/' . $user_id . '/outbox' ); + $response = \rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertSame( + (int) $public_visible, + (int) $data['totalItems'], + sprintf( + 'Content with visibility "%s" should%s be visible to logged-out users.', + $visibility ?? 'none', + $public_visible ? '' : ' not' + ) + ); + + // Test as logged-in user with activitypub capability. + \wp_set_current_user( $user_id ); + $this->assertTrue( \current_user_can( 'activitypub' ) ); + + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/actors/' . $user_id . '/outbox' ); + $response = \rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertSame( + (int) $private_visible, + (int) $data['totalItems'], + sprintf( + 'Content with visibility "%s" should%s be visible to users with activitypub capability.', + $visibility ?? 'none', + $private_visible ? '' : ' not' + ) + ); + + \wp_delete_post( $post_id, true ); + \wp_delete_user( $user_id ); + } + + /** + * Test get_item method. + * + * @doesNotPerformAssertions + */ + public function test_get_item() { + // Controller does not implement get_item(). + } + + /** + * Test get_item_schema method. + * + * @doesNotPerformAssertions + */ + public function test_get_item_schema() { + // Controller does not implement get_item_schema(). + } +}