diff --git a/activitypub.php b/activitypub.php index 1774583568..911168f703 100644 --- a/activitypub.php +++ b/activitypub.php @@ -78,6 +78,7 @@ function rest_init() { */ function plugin_init() { \add_action( 'init', array( __NAMESPACE__ . '\Activitypub', 'init' ) ); + \add_action( 'init', array( __NAMESPACE__ . '\Application', 'init' ) ); \add_action( 'init', array( __NAMESPACE__ . '\Avatars', 'init' ) ); \add_action( 'init', array( __NAMESPACE__ . '\Cache', 'init' ) ); \add_action( 'init', array( __NAMESPACE__ . '\Comment', 'init' ) ); diff --git a/includes/class-application.php b/includes/class-application.php new file mode 100644 index 0000000000..d2b6561a8b --- /dev/null +++ b/includes/class-application.php @@ -0,0 +1,331 @@ + 'sha512', + 'private_key_bits' => 2048, + 'private_key_type' => \OPENSSL_KEYTYPE_RSA, + ); + + $key = \openssl_pkey_new( $config ); + $private_key = null; + $detail = array(); + if ( $key ) { + \openssl_pkey_export( $key, $private_key ); + $detail = \openssl_pkey_get_details( $key ); + } + + // Check if keys are valid. + if ( + empty( $private_key ) || ! \is_string( $private_key ) || + ! isset( $detail['key'] ) || ! \is_string( $detail['key'] ) + ) { + return array( + 'private_key' => null, + 'public_key' => null, + ); + } + + $key_pair = array( + 'private_key' => $private_key, + 'public_key' => $detail['key'], + ); + + \update_option( self::KEYPAIR_OPTION_KEY, $key_pair ); + + return $key_pair; + } + + /** + * Check if the URI matches the Application actor and return WebFinger data. + * + * @since unreleased + * + * Handles the following URI formats: + * - acct:application@example.com / application@example.com + * - http(s)://example.com/@application + * - http(s)://example.com/wp-json/activitypub/1.0/application + * + * @param string $uri The WebFinger resource URI. + * + * @return array|false The WebFinger profile data or false if not the Application. + */ + public static function get_webfinger_data( $uri ) { + if ( ! self::is_application_resource( $uri ) ) { + return false; + } + + $application_id = self::get_id(); + + return array( + 'subject' => sprintf( 'acct:%s', self::get_webfinger() ), + 'aliases' => array( $application_id ), + 'links' => array( + array( + 'rel' => 'self', + 'type' => 'application/activity+json', + 'href' => $application_id, + 'properties' => array( + 'https://www.w3.org/ns/activitystreams#type' => 'Application', + ), + ), + ), + ); + } + + /** + * Check if a URI refers to the Application actor. + * + * @since unreleased + * + * @param string $uri The URI to check. + * + * @return bool True if the URI refers to the Application. + */ + public static function is_application_resource( $uri ) { + $scheme = 'acct'; + $match = array(); + + if ( \preg_match( '/^([a-zA-Z][a-zA-Z0-9+\-.]*):(.*)$/i', $uri, $match ) ) { + $scheme = \strtolower( $match[1] ); + } + + switch ( $scheme ) { + case 'http': + case 'https': + // Check for http(s)://example.com/@application. + $resource_path = \wp_parse_url( $uri, PHP_URL_PATH ); + + if ( $resource_path ) { + $blog_path = \wp_parse_url( \home_url(), PHP_URL_PATH ); + + if ( $blog_path ) { + $resource_path = \str_replace( $blog_path, '', $resource_path ); + } + + $resource_path = \trim( $resource_path, '/' ); + + if ( '@' . self::USERNAME === $resource_path ) { + return true; + } + } + + // Check for the REST API URL. + if ( normalize_url( $uri ) === normalize_url( self::get_id() ) ) { + return true; + } + + return false; + + case 'acct': + default: + $uri_clean = \str_replace( 'acct:', '', $uri ); + $host = home_host(); + + if ( self::USERNAME . '@' . $host === $uri_clean ) { + return true; + } + + // Also check normalized host. + $normalized_host = normalize_host( $host ); + $uri_host = \strrchr( $uri_clean, '@' ); + $uri_host = false !== $uri_host ? \substr( $uri_host, 1 ) : false; + + if ( ! $uri_host || normalize_host( $uri_host ) !== $normalized_host ) { + return false; + } + + $identifier = \substr( $uri_clean, 0, \strrpos( $uri_clean, '@' ) ); + + return self::USERNAME === $identifier; + } + } + + /** + * Checks for legacy key pair options. + * + * @since unreleased + * + * @return array|false The key pair or false. + */ + private static function check_legacy_key_pair() { + $public_key = \get_option( 'activitypub_application_user_public_key' ); + $private_key = \get_option( 'activitypub_application_user_private_key' ); + + if ( ! empty( $public_key ) && \is_string( $public_key ) && ! empty( $private_key ) && \is_string( $private_key ) ) { + return array( + 'private_key' => $private_key, + 'public_key' => $public_key, + ); + } + + return false; + } +} diff --git a/includes/class-http.php b/includes/class-http.php index 7f99d46ae3..5d14fe2dca 100644 --- a/includes/class-http.php +++ b/includes/class-http.php @@ -166,8 +166,8 @@ public static function get( $url, $args = array(), $cached = false ) { 'Content-Type' => 'application/activity+json', 'Date' => \gmdate( 'D, d M Y H:i:s T' ), ), - 'key_id' => Actors::get_by_id( Actors::APPLICATION_USER_ID )->get_id() . '#main-key', - 'private_key' => Actors::get_private_key( Actors::APPLICATION_USER_ID ), + 'key_id' => Application::get_key_id(), + 'private_key' => Application::get_private_key(), ); $args = \wp_parse_args( $args, $defaults ); diff --git a/includes/class-mailer.php b/includes/class-mailer.php index ad2ba60d6c..f494833745 100644 --- a/includes/class-mailer.php +++ b/includes/class-mailer.php @@ -144,11 +144,6 @@ public static function new_follower( $activity, $user_ids, $success ) { // Extract the user ID (follows are always for a single user). $user_id = \is_array( $user_ids ) ? \reset( $user_ids ) : $user_ids; - // Do not send notifications to the Application user. - if ( Actors::APPLICATION_USER_ID === $user_id ) { - return; - } - if ( $user_id > Actors::BLOG_USER_ID ) { if ( ! \get_user_option( 'activitypub_mailer_new_follower', $user_id ) ) { return; diff --git a/includes/class-migration.php b/includes/class-migration.php index 8e59ae6260..0d077a655b 100644 --- a/includes/class-migration.php +++ b/includes/class-migration.php @@ -221,6 +221,7 @@ public static function maybe_migrate() { } if ( \version_compare( $version_from_db, 'unreleased', '<' ) ) { + self::migrate_application_keypair_option(); Activitypub::flush_rewrite_rules(); } @@ -1028,7 +1029,7 @@ public static function remove_pending_application_user_follow_requests() { $wpdb->postmeta, array( 'meta_key' => '_activitypub_following', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key - 'meta_value' => Actors::APPLICATION_USER_ID, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value + 'meta_value' => -1, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value ) ); } @@ -1216,4 +1217,18 @@ public static function migrate_actor_emoji( $batch_size = 50, $offset = 0 ) { return null; } + + /** + * Migrate the Application key pair option from the old name to the new name. + * + * Renames `activitypub_keypair_for_-1` to `activitypub_application_keypair`. + * Older separate key options (activitypub_application_user_public_key / + * activitypub_application_user_private_key) are migrated lazily on first read. + * + * @since unreleased + */ + public static function migrate_application_keypair_option() { + \wp_cache_flush(); + self::update_options_key( 'activitypub_keypair_for_-1', Application::KEYPAIR_OPTION_KEY ); + } } diff --git a/includes/class-query.php b/includes/class-query.php index 2030db4ddd..800fb31f64 100644 --- a/includes/class-query.php +++ b/includes/class-query.php @@ -227,10 +227,9 @@ public function get_queried_object() { * Get the virtual object. * * Virtual objects are objects that are not stored in the database, but are created on the fly. - * The plugins currently supports two virtual objects: The Blog-Actor and the Application-Actor. + * The plugin currently supports one virtual object: The Blog-Actor. * * @see \Activitypub\Model\Blog - * @see \Activitypub\Model\Application * * @return object|null The virtual object. */ diff --git a/includes/class-router.php b/includes/class-router.php index b49934f12f..02c22bc36c 100644 --- a/includes/class-router.php +++ b/includes/class-router.php @@ -63,6 +63,8 @@ public static function add_rewrite_rules() { ); } + // Must precede the generic @username rule, which would otherwise match "application" as an actor username. + \add_rewrite_rule( '^@application\/?$', 'index.php?rest_route=/' . ACTIVITYPUB_REST_NAMESPACE . '/application', 'top' ); \add_rewrite_rule( '^@([\w\-\.]+)\/?$', 'index.php?actor=$matches[1]', 'top' ); \add_rewrite_endpoint( 'activitypub', EP_AUTHORS | EP_PERMALINK | EP_PAGES ); } diff --git a/includes/cli/class-actor-command.php b/includes/cli/class-actor-command.php index 06477a536a..e48ebfdfab 100644 --- a/includes/cli/class-actor-command.php +++ b/includes/cli/class-actor-command.php @@ -7,7 +7,6 @@ namespace Activitypub\Cli; -use Activitypub\Collection\Actors; use Activitypub\Scheduler\Actor; /** @@ -39,10 +38,6 @@ class Actor_Command extends \WP_CLI_Command { * @param array $assoc_args The associative arguments (unused). */ public function delete( $args, $assoc_args ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable - if ( Actors::APPLICATION_USER_ID === (int) $args[0] ) { - \WP_CLI::error( 'You cannot delete the application actor.' ); - } - \add_filter( 'activitypub_user_can_activitypub', '__return_true' ); Actor::schedule_user_delete( $args[0] ); \remove_filter( 'activitypub_user_can_activitypub', '__return_true' ); diff --git a/includes/collection/class-actors.php b/includes/collection/class-actors.php index 71deb0ec4b..cfa1c34821 100644 --- a/includes/collection/class-actors.php +++ b/includes/collection/class-actors.php @@ -8,7 +8,6 @@ namespace Activitypub\Collection; use Activitypub\Activity\Actor; -use Activitypub\Model\Application; use Activitypub\Model\Blog; use Activitypub\Model\User; @@ -32,13 +31,6 @@ class Actors { */ const BLOG_USER_ID = 0; - /** - * The ID of the Application User. - * - * @var int - */ - const APPLICATION_USER_ID = -1; - /** * Get the Actor by ID. * @@ -62,8 +54,6 @@ public static function get_by_id( $user_id ) { switch ( $user_id ) { case self::BLOG_USER_ID: return new Blog(); - case self::APPLICATION_USER_ID: - return new Application(); default: return User::from_wp_user( $user_id ); } @@ -120,11 +110,6 @@ public static function get_id_by_username( $username ) { return self::BLOG_USER_ID; } - // Check for application user. - if ( 'application' === $username ) { - return self::APPLICATION_USER_ID; - } - // Check for 'activitypub_username' meta. $user = new \WP_User_Query( array( @@ -421,10 +406,6 @@ static function ( $actor ) { public static function get_type_by_id( $user_id ) { $user_id = (int) $user_id; - if ( self::APPLICATION_USER_ID === $user_id ) { - return 'application'; - } - if ( self::BLOG_USER_ID === $user_id ) { return 'blog'; } @@ -570,10 +551,6 @@ protected static function check_legacy_key_pair( $user_id ) { $public_key = \get_option( 'activitypub_blog_user_public_key' ); $private_key = \get_option( 'activitypub_blog_user_private_key' ); break; - case -1: - $public_key = \get_option( 'activitypub_application_user_public_key' ); - $private_key = \get_option( 'activitypub_application_user_private_key' ); - break; default: $public_key = \get_user_meta( $user_id, 'magic_sig_public_key', true ); $private_key = \get_user_meta( $user_id, 'magic_sig_private_key', true ); diff --git a/includes/collection/class-outbox.php b/includes/collection/class-outbox.php index bf96797eaa..945204fac7 100644 --- a/includes/collection/class-outbox.php +++ b/includes/collection/class-outbox.php @@ -340,9 +340,6 @@ public static function get_actor( $outbox_item ) { case 'blog': $actor_id = Actors::BLOG_USER_ID; break; - case 'application': - $actor_id = Actors::APPLICATION_USER_ID; - break; case 'user': default: $actor_id = $outbox_item->post_author; diff --git a/includes/functions-user.php b/includes/functions-user.php index 59632fc5b9..cd36a2b2b3 100644 --- a/includes/functions-user.php +++ b/includes/functions-user.php @@ -108,10 +108,6 @@ function user_can_activitypub( $user_id ) { } switch ( $user_id ) { - case Actors::APPLICATION_USER_ID: - $enabled = true; // Application user is always enabled. - break; - case Actors::BLOG_USER_ID: $enabled = ! is_user_type_disabled( 'blog' ); break; diff --git a/includes/handler/class-follow.php b/includes/handler/class-follow.php index 3095411875..85c71d3c83 100644 --- a/includes/handler/class-follow.php +++ b/includes/handler/class-follow.php @@ -36,11 +36,6 @@ public static function handle_follow( $activity, $user_ids ) { // Extract the user ID (follow requests are always for a single user). $user_id = \is_array( $user_ids ) ? \reset( $user_ids ) : $user_ids; - if ( Actors::APPLICATION_USER_ID === $user_id ) { - self::queue_reject( $activity, $user_id ); - return; - } - // Check if the actor already follows the user. $already_following = false; $remote_actor = Remote_Actors::get_by_uri( $activity['actor'] ); diff --git a/includes/model/class-application.php b/includes/model/class-application.php index 101b2e162c..2293ff2cb2 100644 --- a/includes/model/class-application.php +++ b/includes/model/class-application.php @@ -2,13 +2,16 @@ /** * Application model file. * + * @deprecated Use {@see \Activitypub\Application} for key management + * and {@see \Activitypub\Rest\Application_Controller} for the REST endpoint. + * * @package Activitypub */ namespace Activitypub\Model; use Activitypub\Activity\Actor; -use Activitypub\Collection\Actors; +use Activitypub\Application as Application_Utility; use function Activitypub\get_rest_url_by_path; use function Activitypub\home_host; @@ -16,7 +19,7 @@ /** * Application class. * - * @method int get__id() Gets the internal user ID for the application (always returns APPLICATION_USER_ID). + * @deprecated Use {@see \Activitypub\Application} instead. */ class Application extends Actor { /** @@ -24,7 +27,7 @@ class Application extends Actor { * * @var int */ - protected $_id = Actors::APPLICATION_USER_ID; // phpcs:ignore PSR2.Classes.PropertyDeclaration.Underscore + protected $_id = -1; // phpcs:ignore PSR2.Classes.PropertyDeclaration.Underscore /** * Whether the Application is discoverable. @@ -101,13 +104,20 @@ class Application extends Actor { */ protected $preferred_username = 'application'; + /** + * Constructor. + */ + public function __construct() { + \_deprecated_class( __CLASS__, 'unreleased', 'Activitypub\Application' ); + } + /** * Returns the ID of the Application. * * @return string The ID of the Application. */ public function get_id() { - return get_rest_url_by_path( 'application' ); + return Application_Utility::get_id(); } /** @@ -186,15 +196,17 @@ public function get_header_image() { public function get_published() { $first_post = new \WP_Query( array( - 'orderby' => 'date', - 'order' => 'ASC', - 'number' => 1, + 'orderby' => 'date', + 'order' => 'ASC', + 'posts_per_page' => 1, ) ); if ( ! empty( $first_post->posts[0] ) ) { $time = \strtotime( $first_post->posts[0]->post_date_gmt ); - } else { + } + + if ( empty( $time ) ) { $time = \time(); } @@ -207,7 +219,7 @@ public function get_published() { * @return string The Inbox-Endpoint. */ public function get_inbox() { - return get_rest_url_by_path( sprintf( 'actors/%d/inbox', $this->get__id() ) ); + return get_rest_url_by_path( 'inbox' ); } /** @@ -216,7 +228,7 @@ public function get_inbox() { * @return string The Outbox-Endpoint. */ public function get_outbox() { - return get_rest_url_by_path( sprintf( 'actors/%d/outbox', $this->get__id() ) ); + return get_rest_url_by_path( 'application/outbox' ); } /** @@ -235,9 +247,9 @@ public function get_webfinger() { */ public function get_public_key() { return array( - 'id' => $this->get_id() . '#main-key', + 'id' => Application_Utility::get_key_id(), 'owner' => $this->get_id(), - 'publicKeyPem' => Actors::get_public_key( Actors::APPLICATION_USER_ID ), + 'publicKeyPem' => Application_Utility::get_public_key(), ); } diff --git a/includes/rest/class-application-controller.php b/includes/rest/class-application-controller.php index f577437c9e..9c4901f5b0 100644 --- a/includes/rest/class-application-controller.php +++ b/includes/rest/class-application-controller.php @@ -2,12 +2,22 @@ /** * Application Controller file. * + * Self-contained controller for the ActivityPub Application actor. + * The Application is not a real actor in the plugin's internal sense — + * it cannot be followed, addressed, or interacted with. It exists only as: + * 1. A JSON-LD document at /wp-json/activitypub/1.0/application + * 2. A signing identity for outbound HTTP GET requests + * * @package Activitypub */ namespace Activitypub\Rest; -use Activitypub\Model\Application; +use Activitypub\Activity\Actor; +use Activitypub\Application; + +use function Activitypub\get_rest_url_by_path; +use function Activitypub\home_host; /** * ActivityPub Application Controller. @@ -43,16 +53,90 @@ public function register_routes() { 'schema' => array( $this, 'get_item_schema' ), ) ); + + \register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/outbox', + array( + array( + 'methods' => \WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_outbox' ), + 'permission_callback' => '__return_true', + ), + ) + ); } /** * Retrieves the application actor profile. * + * @since unreleased + * * @param \WP_REST_Request $request The request object. * @return \WP_REST_Response Response object. */ public function get_item( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable - $json = ( new Application() )->to_array(); + $id = Application::get_id(); + + $json = array( + '@context' => Actor::JSON_LD_CONTEXT, + 'id' => $id, + 'type' => 'Application', + 'name' => Application::USERNAME, + 'preferredUsername' => Application::USERNAME, + 'summary' => sprintf( + /* translators: %s: Domain of the site */ + \__( 'This is the Application Actor for %s.', 'activitypub' ), + home_host() + ), + 'url' => Application::get_url(), + 'icon' => self::get_icon(), + 'published' => self::get_published(), + 'inbox' => get_rest_url_by_path( 'inbox' ), + 'outbox' => get_rest_url_by_path( 'application/outbox' ), + 'manuallyApprovesFollowers' => true, + 'discoverable' => false, + 'indexable' => false, + 'invisible' => true, + 'webfinger' => Application::USERNAME . '@' . home_host(), + 'publicKey' => array( + 'id' => Application::get_key_id(), + 'owner' => $id, + 'publicKeyPem' => Application::get_public_key(), + ), + 'implements' => array( + array( + 'href' => 'https://datatracker.ietf.org/doc/html/rfc9421', + 'name' => 'RFC-9421: HTTP Message Signatures', + ), + ), + ); + + $rest_response = new \WP_REST_Response( $json, 200 ); + $rest_response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) ); + + return $rest_response; + } + + /** + * Returns an empty outbox collection for the Application actor. + * + * The Application is a signing-only identity and does not publish + * activities, so its outbox is always an empty OrderedCollection. + * + * @since unreleased + * + * @param \WP_REST_Request $request The request object. + * @return \WP_REST_Response Response object. + */ + public function get_outbox( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + $json = array( + '@context' => Actor::JSON_LD_CONTEXT, + 'id' => get_rest_url_by_path( 'application/outbox' ), + 'type' => 'OrderedCollection', + 'totalItems' => 0, + 'orderedItems' => array(), + ); $rest_response = new \WP_REST_Response( $json, 200 ); $rest_response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) ); @@ -60,6 +144,65 @@ public function get_item( $request ) { // phpcs:ignore VariableAnalysis.CodeAnal return $rest_response; } + /** + * Returns the icon for the Application. + * + * @return string[] The icon array with 'type' and 'url'. + */ + private static function get_icon() { + // Try site icon first. + $icon_id = \get_option( 'site_icon' ); + + // Try custom logo second. + if ( ! $icon_id ) { + $icon_id = \get_theme_mod( 'custom_logo' ); + } + + $icon_url = false; + + if ( $icon_id ) { + $icon = \wp_get_attachment_image_src( $icon_id, 'full' ); + if ( $icon ) { + $icon_url = $icon[0]; + } + } + + if ( ! $icon_url ) { + // Fallback to default icon. + $icon_url = \plugins_url( '/assets/img/wp-logo.png', ACTIVITYPUB_PLUGIN_FILE ); + } + + return array( + 'type' => 'Image', + 'url' => \esc_url( $icon_url ), + ); + } + + /** + * Returns the published date. + * + * @return string The published date in RFC3339 format. + */ + private static function get_published() { + $first_post = new \WP_Query( + array( + 'orderby' => 'date', + 'order' => 'ASC', + 'posts_per_page' => 1, + ) + ); + + if ( ! empty( $first_post->posts[0] ) ) { + $time = \strtotime( $first_post->posts[0]->post_date_gmt ); + } + + if ( empty( $time ) ) { + $time = \time(); + } + + return \gmdate( ACTIVITYPUB_DATE_TIME_RFC3339, $time ); + } + /** * Retrieves the schema for the application endpoint. * @@ -157,6 +300,9 @@ public function get_item_schema() { 'indexable' => array( 'type' => 'boolean', ), + 'invisible' => array( + 'type' => 'boolean', + ), 'implements' => array( 'type' => 'array', 'items' => array( diff --git a/includes/wp-admin/class-health-check.php b/includes/wp-admin/class-health-check.php index 98d0fc8bc1..ad98406773 100644 --- a/includes/wp-admin/class-health-check.php +++ b/includes/wp-admin/class-health-check.php @@ -7,6 +7,7 @@ namespace Activitypub\WP_Admin; +use Activitypub\Application; use Activitypub\Collection\Actors; use Activitypub\Collection\Outbox; use Activitypub\Http; @@ -261,8 +262,7 @@ public static function is_author_url_accessible() { * @return boolean|\WP_Error */ public static function is_webfinger_endpoint_accessible() { - $user = Actors::get_by_id( Actors::APPLICATION_USER_ID ); - $resource = $user->get_webfinger(); + $resource = Application::get_webfinger(); $url = Webfinger::resolve( $resource ); if ( \is_wp_error( $url ) ) { @@ -895,9 +895,8 @@ public static function get_outbox_count( $status = 'any' ) { * @return bool|\WP_Error True if accessible, WP_Error otherwise. */ public static function is_rest_api_accessible() { - // Test the application actor's inbox endpoint (always available). - $actor = Actors::get_by_id( Actors::APPLICATION_USER_ID ); - $url = $actor->get_inbox(); + // Test the Application endpoint (always available, publicly readable via GET). + $url = \Activitypub\get_rest_url_by_path( 'application' ); // Make an unauthenticated request. $response = \wp_remote_get( diff --git a/integration/stream/class-connector.php b/integration/stream/class-connector.php index 8331e8834e..1a33cfbe80 100644 --- a/integration/stream/class-connector.php +++ b/integration/stream/class-connector.php @@ -238,8 +238,6 @@ protected function prepare_outbox_data_for_response( $outbox_item ) { $object_title = \get_userdata( $author_id )->display_name; } elseif ( Actors::BLOG_USER_ID === $author_id ) { $object_title = \__( 'Blog User', 'activitypub' ); - } elseif ( Actors::APPLICATION_USER_ID === $author_id ) { - $object_title = \__( 'Application User', 'activitypub' ); } } } diff --git a/tests/phpunit/tests/includes/class-test-migration.php b/tests/phpunit/tests/includes/class-test-migration.php index 4b4438910e..3dbae2e359 100644 --- a/tests/phpunit/tests/includes/class-test-migration.php +++ b/tests/phpunit/tests/includes/class-test-migration.php @@ -8,7 +8,7 @@ namespace Activitypub\Tests; use Activitypub\Activity\Actor; -use Activitypub\Collection\Actors; +use Activitypub\Application; use Activitypub\Collection\Extra_Fields; use Activitypub\Collection\Followers; use Activitypub\Collection\Following; @@ -907,22 +907,22 @@ public function test_remove_pending_application_user_follow_requests() { $post3 = self::factory()->post->create(); // Add _activitypub_following meta with APPLICATION_USER_ID value. - \add_post_meta( $post1, '_activitypub_following', Actors::APPLICATION_USER_ID ); - \add_post_meta( $post2, '_activitypub_following', Actors::APPLICATION_USER_ID ); + \add_post_meta( $post1, '_activitypub_following', -1 ); + \add_post_meta( $post2, '_activitypub_following', -1 ); // Add _activitypub_following meta with different values (should not be removed). \add_post_meta( $post3, '_activitypub_following', '123' ); \add_post_meta( $post1, '_activitypub_following', '456' ); // Add other meta keys (should not be affected). - \add_post_meta( $post1, '_activitypub_other_meta', Actors::APPLICATION_USER_ID ); - \add_post_meta( $post2, 'some_other_meta', Actors::APPLICATION_USER_ID ); + \add_post_meta( $post1, '_activitypub_other_meta', -1 ); + \add_post_meta( $post2, 'some_other_meta', -1 ); // Verify initial state. $initial_count = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = '_activitypub_following' AND meta_value = %s", - Actors::APPLICATION_USER_ID + -1 ) ); $this->assertEquals( 2, $initial_count, 'Should have 2 _activitypub_following entries with APPLICATION_USER_ID' ); @@ -930,7 +930,7 @@ public function test_remove_pending_application_user_follow_requests() { $other_following_count = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = '_activitypub_following' AND meta_value != %s", - Actors::APPLICATION_USER_ID + -1 ) ); $this->assertEquals( 2, $other_following_count, 'Should have 2 _activitypub_following entries with other values' ); @@ -942,7 +942,7 @@ public function test_remove_pending_application_user_follow_requests() { $remaining_count = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = '_activitypub_following' AND meta_value = %s", - Actors::APPLICATION_USER_ID + -1 ) ); $this->assertEquals( 0, $remaining_count, 'All _activitypub_following entries with APPLICATION_USER_ID should be removed' ); @@ -951,14 +951,14 @@ public function test_remove_pending_application_user_follow_requests() { $remaining_other_count = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = '_activitypub_following' AND meta_value != %s", - Actors::APPLICATION_USER_ID + -1 ) ); $this->assertEquals( 2, $remaining_other_count, 'Other _activitypub_following entries should remain' ); // Verify other meta keys are unaffected. - $this->assertEquals( Actors::APPLICATION_USER_ID, \get_post_meta( $post1, '_activitypub_other_meta', true ), 'Other meta keys should not be affected' ); - $this->assertEquals( Actors::APPLICATION_USER_ID, \get_post_meta( $post2, 'some_other_meta', true ), 'Other meta keys should not be affected' ); + $this->assertEquals( -1, \get_post_meta( $post1, '_activitypub_other_meta', true ), 'Other meta keys should not be affected' ); + $this->assertEquals( -1, \get_post_meta( $post2, 'some_other_meta', true ), 'Other meta keys should not be affected' ); } /** @@ -978,8 +978,8 @@ public function test_remove_pending_application_user_follow_requests_no_matches( \add_post_meta( $post2, '_activitypub_following', '456' ); // Add other meta keys with APPLICATION_USER_ID. - \add_post_meta( $post1, '_activitypub_other_meta', Actors::APPLICATION_USER_ID ); - \add_post_meta( $post2, 'different_meta', Actors::APPLICATION_USER_ID ); + \add_post_meta( $post1, '_activitypub_other_meta', -1 ); + \add_post_meta( $post2, 'different_meta', -1 ); // Get initial counts. $initial_following_count = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery @@ -1006,8 +1006,8 @@ public function test_remove_pending_application_user_follow_requests_no_matches( // Verify specific entries remain. $this->assertEquals( '123', \get_post_meta( $post1, '_activitypub_following', true ), '_activitypub_following with different value should remain' ); $this->assertEquals( '456', \get_post_meta( $post2, '_activitypub_following', true ), '_activitypub_following with different value should remain' ); - $this->assertEquals( Actors::APPLICATION_USER_ID, \get_post_meta( $post1, '_activitypub_other_meta', true ), 'Other meta keys should not be affected' ); - $this->assertEquals( Actors::APPLICATION_USER_ID, \get_post_meta( $post2, 'different_meta', true ), 'Other meta keys should not be affected' ); + $this->assertEquals( -1, \get_post_meta( $post1, '_activitypub_other_meta', true ), 'Other meta keys should not be affected' ); + $this->assertEquals( -1, \get_post_meta( $post2, 'different_meta', true ), 'Other meta keys should not be affected' ); } /** @@ -1022,9 +1022,9 @@ public function test_remove_pending_application_user_follow_requests_multiple_en $post_id = self::factory()->post->create(); // Add multiple _activitypub_following meta entries with APPLICATION_USER_ID. - \add_post_meta( $post_id, '_activitypub_following', Actors::APPLICATION_USER_ID ); - \add_post_meta( $post_id, '_activitypub_following', Actors::APPLICATION_USER_ID ); - \add_post_meta( $post_id, '_activitypub_following', Actors::APPLICATION_USER_ID ); + \add_post_meta( $post_id, '_activitypub_following', -1 ); + \add_post_meta( $post_id, '_activitypub_following', -1 ); + \add_post_meta( $post_id, '_activitypub_following', -1 ); // Add one with different value. \add_post_meta( $post_id, '_activitypub_following', '789' ); @@ -1033,7 +1033,7 @@ public function test_remove_pending_application_user_follow_requests_multiple_en $initial_app_count = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = '_activitypub_following' AND meta_value = %s", - Actors::APPLICATION_USER_ID + -1 ) ); $this->assertEquals( 3, $initial_app_count, 'Should have 3 APPLICATION_USER_ID entries' ); @@ -1045,7 +1045,7 @@ public function test_remove_pending_application_user_follow_requests_multiple_en $remaining_app_count = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = '_activitypub_following' AND meta_value = %s", - Actors::APPLICATION_USER_ID + -1 ) ); $this->assertEquals( 0, $remaining_app_count, 'All APPLICATION_USER_ID entries should be removed' ); @@ -1472,4 +1472,83 @@ public function test_migrate_actor_emoji_batching() { $this->assertNotEmpty( $emoji_meta, "Actor {$actor_id} should have emoji meta" ); } } + + /** + * Test migrate_application_keypair_option renames the old option. + * + * @covers ::migrate_application_keypair_option + */ + public function test_migrate_application_keypair_option() { + $key_pair = array( + 'public_key' => 'test-public-key', + 'private_key' => 'test-private-key', + ); + + // Set up the old option name. + \delete_option( Application::KEYPAIR_OPTION_KEY ); + \delete_option( 'activitypub_keypair_for_-1' ); + \add_option( 'activitypub_keypair_for_-1', $key_pair ); + + // Verify old option exists. + $this->assertEquals( $key_pair, \get_option( 'activitypub_keypair_for_-1' ) ); + $this->assertFalse( \get_option( Application::KEYPAIR_OPTION_KEY ) ); + + // Run the migration. + Migration::migrate_application_keypair_option(); + + // Verify option was renamed. + $this->assertFalse( \get_option( 'activitypub_keypair_for_-1' ) ); + $this->assertEquals( $key_pair, \get_option( Application::KEYPAIR_OPTION_KEY ) ); + + // Verify Application class can read the keys. + $this->assertEquals( 'test-public-key', Application::get_public_key() ); + $this->assertEquals( 'test-private-key', Application::get_private_key() ); + + // Clean up. + \delete_option( Application::KEYPAIR_OPTION_KEY ); + } + + /** + * Test migrate_application_keypair_option when old option doesn't exist. + * + * @covers ::migrate_application_keypair_option + */ + public function test_migrate_application_keypair_option_no_old_option() { + // Ensure neither option exists. + \delete_option( 'activitypub_keypair_for_-1' ); + \delete_option( Application::KEYPAIR_OPTION_KEY ); + + // Run the migration — should not error. + Migration::migrate_application_keypair_option(); + + // Both should still not exist. + $this->assertFalse( \get_option( 'activitypub_keypair_for_-1' ) ); + $this->assertFalse( \get_option( Application::KEYPAIR_OPTION_KEY ) ); + } + + /** + * Test migrate_application_keypair_option when new option already exists. + * + * @covers ::migrate_application_keypair_option + */ + public function test_migrate_application_keypair_option_already_migrated() { + $new_key_pair = array( + 'public_key' => 'new-public-key', + 'private_key' => 'new-private-key', + ); + + // Set up the new option (already migrated). + \delete_option( 'activitypub_keypair_for_-1' ); + \delete_option( Application::KEYPAIR_OPTION_KEY ); + \add_option( Application::KEYPAIR_OPTION_KEY, $new_key_pair ); + + // Run the migration. + Migration::migrate_application_keypair_option(); + + // New option should be unchanged. + $this->assertEquals( $new_key_pair, \get_option( Application::KEYPAIR_OPTION_KEY ) ); + + // Clean up. + \delete_option( Application::KEYPAIR_OPTION_KEY ); + } } diff --git a/tests/phpunit/tests/includes/collection/class-test-actors.php b/tests/phpunit/tests/includes/collection/class-test-actors.php index ce496a403d..f30afa520d 100644 --- a/tests/phpunit/tests/includes/collection/class-test-actors.php +++ b/tests/phpunit/tests/includes/collection/class-test-actors.php @@ -33,12 +33,9 @@ public function tear_down() { parent::tear_down(); \delete_option( 'activitypub_keypair_for_0' ); - \delete_option( 'activitypub_keypair_for_-1' ); \delete_option( 'activitypub_keypair_for_admin' ); \delete_option( 'activitypub_blog_user_public_key' ); \delete_option( 'activitypub_blog_user_private_key' ); - \delete_option( 'activitypub_application_user_public_key' ); - \delete_option( 'activitypub_application_user_private_key' ); \delete_option( 'activitypub_actor_mode' ); \delete_user_meta( 1, 'magic_sig_public_key' ); \delete_user_meta( 1, 'magic_sig_private_key' ); @@ -103,8 +100,8 @@ public function the_resource_provider() { array( 'acct:*@' . $home_host, 'Activitypub\Model\Blog' ), array( 'acct:_@' . $home_host, 'Activitypub\Model\Blog' ), array( 'acct:aksd@' . $home_host, 'WP_Error' ), + array( 'acct:application@' . $home_host, 'WP_Error' ), array( 'admin@' . $home_host, 'Activitypub\Model\User' ), - array( 'acct:application@' . $home_host, 'Activitypub\Model\Application' ), array( $home_url . '/@admin', 'Activitypub\Model\User' ), array( $home_url . '/@blog', 'Activitypub\Model\Blog' ), array( $https_home_url . '/@blog', 'Activitypub\Model\Blog' ), @@ -128,7 +125,6 @@ public function the_resource_provider() { * @covers ::get_type_by_id */ public function test_get_type_by_id() { - $this->assertSame( 'application', Actors::get_type_by_id( Actors::APPLICATION_USER_ID ) ); $this->assertSame( 'blog', Actors::get_type_by_id( Actors::BLOG_USER_ID ) ); $this->assertSame( 'user', Actors::get_type_by_id( 1 ) ); $this->assertSame( 'user', Actors::get_type_by_id( 2 ) ); @@ -186,21 +182,6 @@ public function test_signature_legacy() { $this->assertEquals( $key_pair['public_key'], $public_key ); $this->assertEquals( $key_pair['private_key'], $private_key ); - // Check application user. - \delete_option( 'activitypub_keypair_for_-1' ); - - $public_key = 'public key ' . Actors::APPLICATION_USER_ID; - $private_key = 'private key ' . Actors::APPLICATION_USER_ID; - - \add_option( 'activitypub_application_user_public_key', $public_key ); - \add_option( 'activitypub_application_user_private_key', $private_key ); - - $key_pair = Actors::get_keypair( Actors::APPLICATION_USER_ID ); - - $this->assertNotEmpty( $key_pair ); - $this->assertEquals( $key_pair['public_key'], $public_key ); - $this->assertEquals( $key_pair['private_key'], $private_key ); - // Check blog user. \update_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_AND_BLOG_MODE ); \delete_option( 'activitypub_actor_mode' ); diff --git a/tests/phpunit/tests/includes/collection/class-test-following.php b/tests/phpunit/tests/includes/collection/class-test-following.php index a26573d866..6b580006e0 100644 --- a/tests/phpunit/tests/includes/collection/class-test-following.php +++ b/tests/phpunit/tests/includes/collection/class-test-following.php @@ -374,13 +374,11 @@ public function test_unfollow() { $outbox_item_2 = \get_post( follow( 'https://example.com/actor/1', $user_ids[1] ) ); $outbox_item_3 = \get_post( follow( 'https://example.com/actor/1', $user_ids[2] ) ); $outbox_item_4 = \get_post( follow( 'https://example.com/actor/1', 0 ) ); - $outbox_item_5 = \get_post( follow( 'https://example.com/actor/1', -1 ) ); \wp_publish_post( $outbox_item_1 ); \wp_publish_post( $outbox_item_2 ); \wp_publish_post( $outbox_item_3 ); \wp_publish_post( $outbox_item_4 ); - \wp_publish_post( $outbox_item_5 ); $accept_1 = array( 'object' => array( @@ -406,28 +404,17 @@ public function test_unfollow() { 'object' => 'https://example.com/actor/1', ), ); - $accept_5 = array( - 'object' => array( - 'id' => $outbox_item_5->guid, - 'object' => 'https://example.com/actor/1', - ), - ); Accept::handle_accept( $accept_1, $user_ids[0] ); Accept::handle_accept( $accept_2, $user_ids[1] ); Accept::handle_accept( $accept_3, $user_ids[2] ); Accept::handle_accept( $accept_4, 0 ); - Accept::handle_accept( $accept_5, -1 ); // User 1 follows https://example.com/actor/1. $following = Following::query( $user_ids[0] ); $this->assertCount( 1, $following['following'] ); $this->assertSame( 1, $following['total'] ); - $following = Following::query( -1 ); - $this->assertCount( 1, $following['following'] ); - $this->assertSame( 1, $following['total'] ); - // User 3 unfollows https://example.com/actor/1. Following::unfollow( Remote_Actors::get_by_uri( 'https://example.com/actor/1' ), $user_ids[2] ); @@ -438,10 +425,6 @@ public function test_unfollow() { $this->assertCount( 0, $following['following'] ); $this->assertSame( 0, $following['total'] ); - $following = Following::query( -1 ); - $this->assertCount( 1, $following['following'] ); - $this->assertSame( 1, $following['total'] ); - // User 1 still follows https://example.com/actor/1. $posts = get_posts( array( diff --git a/tests/phpunit/tests/includes/handler/class-test-follow.php b/tests/phpunit/tests/includes/handler/class-test-follow.php index ab2de4b296..16b59d72ea 100644 --- a/tests/phpunit/tests/includes/handler/class-test-follow.php +++ b/tests/phpunit/tests/includes/handler/class-test-follow.php @@ -123,21 +123,14 @@ public function test_handle_follow( $target_user_id, $actor_url, $expected_respo */ public function handle_follow_provider() { return array( - 'application_user_follow' => array( - Actors::APPLICATION_USER_ID, - 'https://example.com/actor', - 'Reject', - false, - 'Following application user should be rejected', - ), - 'regular_user_follow' => array( + 'regular_user_follow' => array( 'test_user', 'https://example.com/regular-actor', 'Accept', true, 'Following regular user should be accepted', ), - 'subdomain_actor_follow' => array( + 'subdomain_actor_follow' => array( 'test_user', 'https://social.example.com/users/actor', 'Accept', diff --git a/tests/phpunit/tests/includes/rest/class-test-application-controller.php b/tests/phpunit/tests/includes/rest/class-test-application-controller.php index 1b8cd8e3a6..b1aa1855dc 100644 --- a/tests/phpunit/tests/includes/rest/class-test-application-controller.php +++ b/tests/phpunit/tests/includes/rest/class-test-application-controller.php @@ -7,6 +7,9 @@ namespace Activitypub\Tests\Rest; +use Activitypub\Application; +use Activitypub\Rest\Application_Controller; + /** * Tests for Application REST API endpoint. * @@ -23,6 +26,7 @@ class Test_Application_Controller extends \Activitypub\Tests\Test_REST_Controlle public function test_register_routes() { $routes = rest_get_server()->get_routes(); $this->assertArrayHasKey( '/' . ACTIVITYPUB_REST_NAMESPACE . '/application', $routes ); + $this->assertArrayHasKey( '/' . ACTIVITYPUB_REST_NAMESPACE . '/application/outbox', $routes ); } /** @@ -71,8 +75,14 @@ public function test_get_item() { // Test property values. $this->assertEquals( 'Application', $data['type'] ); $this->assertStringContainsString( '/activitypub/1.0/application', $data['id'] ); - $this->assertStringContainsString( '/activitypub/1.0/actors/-1/inbox', $data['inbox'] ); - $this->assertStringContainsString( '/activitypub/1.0/actors/-1/outbox', $data['outbox'] ); + $this->assertStringContainsString( '/activitypub/1.0/inbox', $data['inbox'] ); + $this->assertStringContainsString( '/activitypub/1.0/application/outbox', $data['outbox'] ); + + // Test that Application is not discoverable. + $this->assertFalse( $data['discoverable'] ); + $this->assertFalse( $data['indexable'] ); + $this->assertTrue( $data['invisible'] ); + $this->assertTrue( $data['manuallyApprovesFollowers'] ); } /** @@ -85,9 +95,76 @@ public function test_response_matches_schema() { $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/application' ); $response = rest_get_server()->dispatch( $request ); $data = $response->get_data(); - $schema = ( new \Activitypub\Rest\Application_Controller() )->get_item_schema(); + $schema = ( new Application_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() : '' ) ); } + + /** + * Test get_outbox response. + * + * @covers ::get_outbox + */ + public function test_get_outbox() { + $request = new \WP_REST_Request( 'GET', '/' . ACTIVITYPUB_REST_NAMESPACE . '/application/outbox' ); + $response = rest_get_server()->dispatch( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertStringContainsString( 'application/activity+json', $response->get_headers()['Content-Type'] ); + + $data = $response->get_data(); + $this->assertEquals( 'OrderedCollection', $data['type'] ); + $this->assertEquals( 0, $data['totalItems'] ); + $this->assertIsArray( $data['orderedItems'] ); + $this->assertEmpty( $data['orderedItems'] ); + $this->assertStringContainsString( '/activitypub/1.0/application/outbox', $data['id'] ); + } + + /** + * Test key management methods. + * + * @covers \Activitypub\Application::get_key_id + * @covers \Activitypub\Application::get_public_key + * @covers \Activitypub\Application::get_private_key + */ + public function test_key_management() { + \delete_option( Application::KEYPAIR_OPTION_KEY ); + + $key_id = Application::get_key_id(); + $public_key = Application::get_public_key(); + $private_key = Application::get_private_key(); + + $this->assertStringContainsString( '#main-key', $key_id ); + $this->assertStringContainsString( '/activitypub/1.0/application', $key_id ); + $this->assertNotEmpty( $public_key ); + $this->assertNotEmpty( $private_key ); + + // Keys should be consistent across calls. + $this->assertEquals( $public_key, Application::get_public_key() ); + $this->assertEquals( $private_key, Application::get_private_key() ); + } + + /** + * Test legacy key pair migration. + * + * @covers \Activitypub\Application::get_public_key + * @covers \Activitypub\Application::get_private_key + */ + public function test_legacy_key_pair() { + \delete_option( Application::KEYPAIR_OPTION_KEY ); + + $public_key = 'legacy-public-key'; + $private_key = 'legacy-private-key'; + + \add_option( 'activitypub_application_user_public_key', $public_key ); + \add_option( 'activitypub_application_user_private_key', $private_key ); + + $this->assertEquals( $public_key, Application::get_public_key() ); + $this->assertEquals( $private_key, Application::get_private_key() ); + + \delete_option( 'activitypub_application_user_public_key' ); + \delete_option( 'activitypub_application_user_private_key' ); + \delete_option( Application::KEYPAIR_OPTION_KEY ); + } } diff --git a/tests/phpunit/tests/integration/class-test-webfinger.php b/tests/phpunit/tests/integration/class-test-webfinger.php index 3158bfa80f..96a7463b23 100644 --- a/tests/phpunit/tests/integration/class-test-webfinger.php +++ b/tests/phpunit/tests/integration/class-test-webfinger.php @@ -7,6 +7,7 @@ namespace Activitypub\Tests\Integration; +use Activitypub\Application; use Activitypub\Collection\Actors; use Activitypub\Integration\Webfinger; @@ -59,10 +60,11 @@ public static function wpSetUpBeforeClass( $factory ) { */ public function tear_down() { // Remove filters that may have been added during tests. - remove_filter( 'webfinger_user_data', array( Webfinger::class, 'add_user_discovery' ), 1 ); - remove_filter( 'webfinger_data', array( Webfinger::class, 'add_pseudo_user_discovery' ), 1 ); - remove_filter( 'webfinger_user_data', array( Webfinger::class, 'add_interaction_links' ), 1 ); - remove_filter( 'webfinger_data', array( Webfinger::class, 'add_interaction_links' ), 1 ); + \remove_filter( 'webfinger_user_data', array( Webfinger::class, 'add_user_discovery' ), 1 ); + \remove_filter( 'webfinger_data', array( Webfinger::class, 'add_pseudo_user_discovery' ), 1 ); + \remove_filter( 'webfinger_user_data', array( Webfinger::class, 'add_interaction_links' ), 1 ); + \remove_filter( 'webfinger_data', array( Webfinger::class, 'add_interaction_links' ), 1 ); + \remove_filter( 'webfinger_data', array( Application::class, 'add_webfinger_discovery' ), 2 ); parent::tear_down(); } @@ -297,6 +299,48 @@ public function test_add_interaction_links_templates() { } } + /** + * Test that Application WebFinger filter overrides the WP_Error from + * add_pseudo_user_discovery for the application resource. + * + * Integration\Webfinger::add_pseudo_user_discovery (priority 1) returns + * WP_Error for 'application' because it is not in the Actors collection. + * Application::add_webfinger_discovery (priority 2) must run afterward + * and replace the error with valid JRD data. + * + * @covers \Activitypub\Application::add_webfinger_discovery + */ + public function test_application_webfinger_overrides_pseudo_user_error() { + // Register both filters as they would be in production. + Webfinger::init(); + Application::init(); + + $uri = 'acct:' . Application::USERNAME . '@' . \wp_parse_url( \home_url(), PHP_URL_HOST ); + $jrd = array( + 'subject' => $uri, + 'aliases' => array(), + 'links' => array(), + ); + + $result = \apply_filters( 'webfinger_data', $jrd, $uri ); + + // Must be an array (not WP_Error) with the correct subject. + $this->assertIsArray( $result, 'Application WebFinger should return valid JRD, not WP_Error.' ); + $this->assertArrayHasKey( 'subject', $result ); + $this->assertStringContainsString( Application::USERNAME, $result['subject'] ); + + // Must have an ActivityPub self link pointing to the Application ID. + $self_link = null; + foreach ( $result['links'] as $link ) { + if ( 'self' === $link['rel'] && 'application/activity+json' === $link['type'] ) { + $self_link = $link; + break; + } + } + $this->assertNotNull( $self_link, 'Should have ActivityPub self link.' ); + $this->assertEquals( Application::get_id(), $self_link['href'] ); + } + /** * Test that methods are static. *