From 641b75c15ae31828570754465963ae1eb5b2e684 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Fri, 10 Jan 2025 09:06:56 -0600 Subject: [PATCH] Update webfinger endpoint --- activitypub.php | 2 +- includes/rest/class-webfinger-controller.php | 139 ++++++++---- tests/bootstrap.php | 1 + tests/class-test-rest-controller-testcase.php | 79 +++++++ .../rest/class-test-webfinger-controller.php | 197 ++++++++++++++++++ 5 files changed, 376 insertions(+), 42 deletions(-) create mode 100644 tests/class-test-rest-controller-testcase.php create mode 100644 tests/includes/rest/tests/includes/rest/class-test-webfinger-controller.php diff --git a/activitypub.php b/activitypub.php index f59ede795..594dd1463 100644 --- a/activitypub.php +++ b/activitypub.php @@ -44,12 +44,12 @@ function rest_init() { Rest\Inbox::init(); Rest\Followers::init(); Rest\Following::init(); - Rest\Webfinger::init(); Rest\Comment::init(); Rest\Server::init(); Rest\Collection::init(); Rest\Interaction::init(); Rest\Post::init(); + ( new Rest\Webfinger_Controller() )->register_routes(); // Load NodeInfo endpoints only if blog is public. if ( is_blog_public() ) { diff --git a/includes/rest/class-webfinger-controller.php b/includes/rest/class-webfinger-controller.php index 3348eae8c..ee75e7add 100644 --- a/includes/rest/class-webfinger-controller.php +++ b/includes/rest/class-webfinger-controller.php @@ -7,8 +7,6 @@ namespace Activitypub\Rest; -use WP_REST_Response; - /** * ActivityPub WebFinger REST-Class. * @@ -16,49 +14,63 @@ * * @see https://webfinger.net/ */ -class Webfinger { +class Webfinger_Controller extends \WP_REST_Controller { /** - * Initialize the class, registering WordPress hooks. + * The namespace of this controller's route. + * + * @var string */ - public static function init() { - self::register_routes(); - } + protected $namespace = ACTIVITYPUB_REST_NAMESPACE; + + /** + * The base of this controller's route. + * + * @var string + */ + protected $rest_base = 'webfinger'; /** * Register routes. */ - public static function register_routes() { + public function register_routes() { \register_rest_route( - ACTIVITYPUB_REST_NAMESPACE, - '/webfinger', + $this->namespace, + '/' . $this->rest_base, array( array( 'methods' => \WP_REST_Server::READABLE, - 'callback' => array( self::class, 'webfinger' ), - 'args' => self::request_parameters(), + 'callback' => array( $this, 'get_item' ), 'permission_callback' => '__return_true', + 'args' => array( + 'resource' => array( + 'description' => 'The WebFinger resource.', + 'type' => 'string', + 'required' => true, + 'pattern' => '^(acct:)|^(https?://)(.+)$', + ), + ), ), + 'schema' => array( $this, 'get_item_schema' ), ) ); } /** - * WebFinger endpoint. + * Retrieves the WebFinger profile. * * @param \WP_REST_Request $request The request object. * - * @return WP_REST_Response The response object. + * @return \WP_REST_Response Response object. */ - public static function webfinger( $request ) { + public function get_item( $request ) { /** * Action triggered prior to the ActivityPub profile being created and sent to the client. */ \do_action( 'activitypub_rest_webfinger_pre' ); - $code = 200; - $resource = $request->get_param( 'resource' ); - $response = self::get_profile( $resource ); + $response = $this->get_profile( $resource ); + $code = 200; if ( \is_wp_error( $response ) ) { $code = 400; @@ -69,48 +81,93 @@ public static function webfinger( $request ) { } } - return new WP_REST_Response( + return new \WP_REST_Response( $response, $code, array( 'Access-Control-Allow-Origin' => '*', - 'Content-Type' => 'application/jrd+json; charset=' . get_option( 'blog_charset' ), + 'Content-Type' => 'application/jrd+json; charset=' . \get_option( 'blog_charset' ), ) ); } - /** - * The supported parameters. - * - * @return array list of parameters - */ - public static function request_parameters() { - $params = array(); - - $params['resource'] = array( - 'required' => true, - 'type' => 'string', - 'pattern' => '^(acct:)|^(https?://)(.+)$', - 'sanitize_callback' => 'sanitize_text_field', - ); - - return $params; - } - /** * Get the WebFinger profile. * - * @param string $webfinger the WebFinger resource. + * @param string $webfinger The WebFinger resource. * * @return array|\WP_Error The WebFinger profile or WP_Error if not found. */ - public static function get_profile( $webfinger ) { + public function get_profile( $webfinger ) { /** * Filter the WebFinger data. * * @param array $data The WebFinger data. * @param string $webfinger The WebFinger resource. */ - return apply_filters( 'webfinger_data', array(), $webfinger ); + return \apply_filters( 'webfinger_data', array(), $webfinger ); + } + + /** + * Retrieves the schema for the WebFinger endpoint. + * + * @return array Schema data. + */ + public function get_item_schema() { + if ( $this->schema ) { + return $this->add_additional_fields_schema( $this->schema ); + } + + $this->schema = array( + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'webfinger', + 'type' => 'object', + 'required' => array( 'subject', 'links' ), + 'properties' => array( + 'subject' => array( + 'description' => 'The subject of this WebFinger record.', + 'type' => 'string', + 'format' => 'uri', + ), + 'aliases' => array( + 'description' => 'Alternative identifiers for the subject.', + 'type' => 'array', + 'items' => array( + 'type' => 'string', + 'format' => 'uri', + ), + ), + 'links' => array( + 'description' => 'Links associated with the subject.', + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'rel' => array( + 'description' => 'The relation type of the link.', + 'type' => 'string', + 'required' => true, + ), + 'type' => array( + 'description' => 'The content type of the link.', + 'type' => 'string', + ), + 'href' => array( + 'description' => 'The target URL of the link.', + 'type' => 'string', + 'format' => 'uri', + ), + 'template' => array( + 'description' => 'A URI template for the link.', + 'type' => 'string', + 'format' => 'uri', + ), + ), + ), + ), + ), + ); + + return $this->add_additional_fields_schema( $this->schema ); } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 719d49dba..ec1919333 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -99,5 +99,6 @@ function http_disable_request( $response, $args, $url ) { // Start up the WP testing environment. require $_tests_dir . '/includes/bootstrap.php'; require __DIR__ . '/class-activitypub-testcase-cache-http.php'; +require __DIR__ . '/class-test-rest-controller-testcase.php'; \Activitypub\Migration::add_default_settings(); diff --git a/tests/class-test-rest-controller-testcase.php b/tests/class-test-rest-controller-testcase.php new file mode 100644 index 000000000..454a7003d --- /dev/null +++ b/tests/class-test-rest-controller-testcase.php @@ -0,0 +1,79 @@ +fail( + sprintf( + 'REST API URL "%s" should have a leading slash.', + $path + ) + ); + } + + return $url; + } +} diff --git a/tests/includes/rest/tests/includes/rest/class-test-webfinger-controller.php b/tests/includes/rest/tests/includes/rest/class-test-webfinger-controller.php new file mode 100644 index 000000000..fc469dfaf --- /dev/null +++ b/tests/includes/rest/tests/includes/rest/class-test-webfinger-controller.php @@ -0,0 +1,197 @@ +user->create_and_get( + array( + 'user_login' => 'test_user', + 'user_email' => 'user@example.org', + ) + ); + self::$user->add_cap( 'activitypub' ); + } + + /** + * Clean up test fixtures. + */ + public static function wpTearDownAfterClass() { + self::delete_user( self::$user->ID ); + } + + /** + * Create test environment. + */ + public function set_up() { + parent::set_up(); + + \add_filter( 'webfinger_data', array( '\Activitypub\Integration\Webfinger', 'add_pseudo_user_discovery' ), 1, 2 ); + } + + /** + * Test route registration. + * + * @covers ::register_routes + */ + public function test_register_routes() { + $routes = rest_get_server()->get_routes(); + $this->assertArrayHasKey( '/' . ACTIVITYPUB_REST_NAMESPACE . '/webfinger', $routes ); + } + + /** + * Test schema. + * + * @covers ::get_item_schema + */ + public function test_get_item_schema() { + $request = new \WP_REST_Request( 'OPTIONS', '/' . ACTIVITYPUB_REST_NAMESPACE . '/webfinger' ); + $response = rest_get_server()->dispatch( $request )->get_data(); + + $this->assertArrayHasKey( 'schema', $response ); + $schema = $response['schema']; + + $this->assertIsArray( $schema ); + $this->assertArrayHasKey( 'properties', $schema ); + $this->assertArrayHasKey( 'subject', $schema['properties'] ); + $this->assertArrayHasKey( 'aliases', $schema['properties'] ); + $this->assertArrayHasKey( 'links', $schema['properties'] ); + } + + /** + * Test get_item with valid resource. + * + * @covers ::get_item + */ + public function test_get_item() { + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/webfinger' ); + $request->set_param( 'resource', 'acct:test_user@example.org' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertStringContainsString( 'application/jrd+json', $response->get_headers()['Content-Type'] ); + $this->assertEquals( '*', $response->get_headers()['Access-Control-Allow-Origin'] ); + } + + /** + * Test get_item with invalid resource. + * + * @covers ::get_item + */ + public function test_get_item_with_invalid_resource() { + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/webfinger' ); + $request->set_param( 'resource', 'invalid-resource' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 400, $response->get_status() ); + } + + /** + * Test get_item with missing resource. + * + * @covers ::get_item + */ + public function test_get_item_with_missing_resource() { + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/webfinger' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 400, $response->get_status() ); + } + + /** + * Test webfinger_data filter. + * + * @covers ::get_profile + */ + public function test_webfinger_data_filter() { + $test_data = array( + 'subject' => 'acct:test_user@example.org', + 'aliases' => array( 'https://example.org/@test_user' ), + 'links' => array( + array( + 'rel' => 'self', + 'type' => 'application/activity+json', + 'href' => 'https://example.org/author/test_user', + ), + ), + ); + + \add_filter( + 'webfinger_data', + function ( $data, $webfinger ) use ( $test_data ) { + $this->assertEquals( 'acct:test_user@example.org', $webfinger ); + return $test_data; + }, + 10, + 2 + ); + + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/webfinger' ); + $request->set_param( 'resource', 'acct:test_user@example.org' ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( $test_data, $data ); + } + + /** + * Test get_item with author URL resource. + * + * @covers ::get_item + */ + public function test_get_item_with_author_url() { + $author_url = \get_author_posts_url( self::$user->ID ); + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/webfinger' ); + $request->set_param( 'resource', $author_url ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertStringContainsString( 'application/jrd+json', $response->get_headers()['Content-Type'] ); + $this->assertContains( $author_url, $data['aliases'] ); + $this->assertArrayHasKey( 'links', $data ); + } + + /** + * Test that the Webfinger response matches its schema. + * + * @covers ::get_item + * @covers ::get_item_schema + */ + public function test_response_matches_schema() { + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/webfinger' ); + $request->set_param( 'resource', 'acct:test_user@example.org' ); + + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $schema = ( new \Activitypub\Rest\Webfinger_Controller() )->get_item_schema(); + + $valid = \rest_validate_value_from_schema( $data, $schema ); + $this->assertNotWPError( $valid, 'Response failed schema validation: ' . ( \is_wp_error( $valid ) ? $valid->get_error_message() : '' ) ); + } +}