diff --git a/.github/changelog/feature-collections-consent b/.github/changelog/feature-collections-consent new file mode 100644 index 0000000000..bda6d15244 --- /dev/null +++ b/.github/changelog/feature-collections-consent @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Add an opt-in setting to consent to inclusion in Starter Kits (also called Starter Packs or Featured Collections). Off by default. Find it under Settings, ActivityPub, Activities. diff --git a/includes/activity/class-activity.php b/includes/activity/class-activity.php index 8f23a5932e..f39d5d984c 100644 --- a/includes/activity/class-activity.php +++ b/includes/activity/class-activity.php @@ -38,9 +38,10 @@ class Activity extends Base_Object { const JSON_LD_CONTEXT = array( 'https://www.w3.org/ns/activitystreams', array( - 'toot' => 'http://joinmastodon.org/ns#', - 'QuoteRequest' => 'toot:QuoteRequest', - 'blurhash' => 'toot:blurhash', + 'toot' => 'http://joinmastodon.org/ns#', + 'QuoteRequest' => 'toot:QuoteRequest', + 'blurhash' => 'toot:blurhash', + 'FeatureRequest' => 'https://w3id.org/fep/7aa9#FeatureRequest', ), ); @@ -71,6 +72,7 @@ class Activity extends Base_Object { 'Move', 'Offer', 'QuoteRequest', // @see https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md + 'FeatureRequest', // @see https://w3id.org/fep/7aa9 (FEP-7aa9 draft) 'Read', 'Reject', 'Remove', diff --git a/includes/activity/class-actor.php b/includes/activity/class-actor.php index 064ab4183a..9e516c87c3 100644 --- a/includes/activity/class-actor.php +++ b/includes/activity/class-actor.php @@ -74,6 +74,7 @@ class Actor extends Base_Object { 'toot' => 'http://joinmastodon.org/ns#', 'lemmy' => 'https://join-lemmy.org/ns#', 'litepub' => 'http://litepub.social/ns#', + 'gts' => 'https://gotosocial.org/ns#', 'manuallyApprovesFollowers' => 'as:manuallyApprovesFollowers', 'PropertyValue' => 'schema:PropertyValue', 'value' => 'schema:value', @@ -107,6 +108,18 @@ class Actor extends Base_Object { '@type' => '@id', '@container' => '@list', ), + 'interactionPolicy' => array( + '@id' => 'gts:interactionPolicy', + '@type' => '@id', + ), + 'canFeature' => array( + '@id' => 'https://w3id.org/fep/7aa9#canFeature', + '@type' => '@id', + ), + 'automaticApproval' => array( + '@id' => 'gts:automaticApproval', + '@type' => '@id', + ), 'postingRestrictedToMods' => 'lemmy:postingRestrictedToMods', 'discoverable' => 'toot:discoverable', 'indexable' => 'toot:indexable', @@ -369,4 +382,43 @@ class Actor extends Base_Object { * @var boolean|null */ protected $invisible = null; + + /** + * Get the actor-level interaction policy. + * + * Overrides the magic property accessor on Base_Object so that we always + * compute the policy from the current site setting rather than returning a + * cached property value. Currently only emits `canFeature` (FEP-7aa9). + * Driven by the site option `activitypub_default_feature_policy` and + * defaults to denying all featured-collection requests, in line with + * FEP-7aa9's "absence of policy = no consent" rule. + * + * @see https://w3id.org/fep/7aa9 + * + * @since unreleased + * + * @return array + */ + public function get_interaction_policy() { + return array_merge( (array) parent::get_interaction_policy(), array( 'canFeature' => $this->build_can_feature_policy() ) ); + } + + /** + * Build the `canFeature` policy array from the site option. + * + * @return array + */ + protected function build_can_feature_policy() { + $policy = \get_option( 'activitypub_default_feature_policy', ACTIVITYPUB_INTERACTION_POLICY_ME ); + + switch ( $policy ) { + case ACTIVITYPUB_INTERACTION_POLICY_ANYONE: + return array( 'automaticApproval' => array( 'https://www.w3.org/ns/activitystreams#Public' ) ); + case ACTIVITYPUB_INTERACTION_POLICY_FOLLOWERS: + return array( 'automaticApproval' => array( $this->get_followers() ) ); + case ACTIVITYPUB_INTERACTION_POLICY_ME: + default: + return array( 'automaticApproval' => array( $this->get_id() ) ); + } + } } diff --git a/includes/activity/extended-object/class-feature-authorization.php b/includes/activity/extended-object/class-feature-authorization.php new file mode 100644 index 0000000000..e615e89316 --- /dev/null +++ b/includes/activity/extended-object/class-feature-authorization.php @@ -0,0 +1,74 @@ + 'https://w3id.org/fep/7aa9#FeatureAuthorization', + 'gts' => 'https://gotosocial.org/ns#', + 'interactingObject' => array( + '@id' => 'gts:interactingObject', + '@type' => '@id', + ), + 'interactionTarget' => array( + '@id' => 'gts:interactionTarget', + '@type' => '@id', + ), + ), + ); + + /** + * The type of the object. + * + * @var string + */ + protected $type = 'FeatureAuthorization'; + + /** + * The object that is being interacted with. + * + * @var Base_Object|string|array|null + */ + protected $interacting_object; + + /** + * The target of the interaction. + * + * @var Base_Object|string|array|null + */ + protected $interaction_target; +} diff --git a/includes/class-handler.php b/includes/class-handler.php index d7fa5d2056..b18e605a59 100644 --- a/includes/class-handler.php +++ b/includes/class-handler.php @@ -28,6 +28,7 @@ public static function register_handlers() { Handler\Collection_Sync::init(); Handler\Create::init(); Handler\Delete::init(); + Handler\Feature_Request::init(); Handler\Follow::init(); Handler\Like::init(); Handler\Move::init(); diff --git a/includes/class-options.php b/includes/class-options.php index 083dc60e95..c589733854 100644 --- a/includes/class-options.php +++ b/includes/class-options.php @@ -201,6 +201,24 @@ public static function register_settings() { ) ); + \register_setting( + 'activitypub', + 'activitypub_default_feature_policy', + array( + 'type' => 'string', + 'description' => 'Default policy for who can include this site\'s actors in featured collections (FEP-7aa9).', + 'default' => ACTIVITYPUB_INTERACTION_POLICY_ME, + 'sanitize_callback' => static function ( $value ) { + $allowed = array( + ACTIVITYPUB_INTERACTION_POLICY_ANYONE, + ACTIVITYPUB_INTERACTION_POLICY_FOLLOWERS, + ACTIVITYPUB_INTERACTION_POLICY_ME, + ); + return \in_array( $value, $allowed, true ) ? $value : ACTIVITYPUB_INTERACTION_POLICY_ME; + }, + ) + ); + \register_setting( 'activitypub', 'activitypub_relays', diff --git a/includes/class-query.php b/includes/class-query.php index 89c443903b..87520b64e1 100644 --- a/includes/class-query.php +++ b/includes/class-query.php @@ -7,6 +7,7 @@ namespace Activitypub; +use Activitypub\Activity\Extended_Object\Feature_Authorization; use Activitypub\Activity\Extended_Object\Quote_Authorization; use Activitypub\Collection\Actors; use Activitypub\Collection\Outbox; @@ -139,8 +140,14 @@ public function get_activitypub_object_id() { private function prepare_activitypub_data() { $queried_object = $this->get_queried_object(); - if ( $queried_object instanceof \WP_Post && \get_query_var( 'stamp' ) ) { - return $this->maybe_get_stamp(); + if ( \get_query_var( 'stamp' ) ) { + if ( $queried_object instanceof \WP_Post ) { + return $this->maybe_get_stamp(); + } + + if ( $queried_object instanceof \WP_User || \get_query_var( 'actor' ) ) { + return $this->maybe_get_actor_stamp(); + } } // Check for Outbox Activity. @@ -433,4 +440,63 @@ private function maybe_get_stamp() { return true; } + + /** + * Maybe get a FeatureAuthorization object from an actor-scoped stamp. + * + * Resolves URLs of the form `?actor=USER_ID&stamp=UMETA_ID` against the + * `_activitypub_featured_by` user meta. The umeta_id doubles as the stamp + * identifier; ownership is enforced by checking the row's user_id matches + * the queried actor. + * + * @return bool True if a FeatureAuthorization was prepared, false otherwise. + */ + private function maybe_get_actor_stamp() { + $stamp_id = (int) \get_query_var( 'stamp' ); + $actor_id = (int) \get_query_var( 'actor' ); + + if ( ! $stamp_id ) { + return false; + } + + if ( ! $actor_id ) { + $queried = $this->get_queried_object(); + if ( $queried instanceof \WP_User ) { + $actor_id = (int) $queried->ID; + } + } + + if ( ! $actor_id ) { + return false; + } + + $meta = \get_metadata_by_mid( 'user', $stamp_id ); + if ( ! $meta || '_activitypub_featured_by' !== $meta->meta_key || (int) $meta->user_id !== $actor_id ) { + return false; + } + + $actor = Actors::get_by_id( $actor_id ); + if ( \is_wp_error( $actor ) ) { + return false; + } + + $stamp_url = \add_query_arg( + array( + 'actor' => $actor_id, + 'stamp' => $meta->umeta_id, + ), + \home_url( '/' ) + ); + + $authorization = new Feature_Authorization(); + $authorization->set_id( $stamp_url ); + $authorization->set_attributed_to( $actor->get_id() ); + $authorization->set_interacting_object( $meta->meta_value ); + $authorization->set_interaction_target( $actor->get_id() ); + + $this->activitypub_object = $authorization; + $this->activitypub_object_id = $authorization->get_id(); + + return true; + } } diff --git a/includes/class-router.php b/includes/class-router.php index e51cced7d1..a94b0541a8 100644 --- a/includes/class-router.php +++ b/includes/class-router.php @@ -290,8 +290,16 @@ public static function template_redirect() { exit; } - $actor = \get_query_var( 'actor', null ); - if ( $actor ) { + /* + * Skip the actor branch when this looks like an actor-scoped FEP-7aa9 + * stamp URL: numeric `actor` paired with a `stamp`. Those resolve to a + * FeatureAuthorization via Activitypub\Query, not via the username + * lookup which would 404 the numeric ID. Non-numeric actors fall + * through to the regular Mastodon-style profile lookup. + */ + $actor = \get_query_var( 'actor', null ); + $is_stamp_url = $actor && \get_query_var( 'stamp' ) && \ctype_digit( (string) $actor ); + if ( $actor && ! $is_stamp_url ) { $actor = Actors::get_by_username( $actor ); if ( ! $actor || \is_wp_error( $actor ) ) { $wp_query->set_404(); diff --git a/includes/handler/class-feature-request.php b/includes/handler/class-feature-request.php new file mode 100644 index 0000000000..d94e7777ae --- /dev/null +++ b/includes/handler/class-feature-request.php @@ -0,0 +1,240 @@ +get__id(); + + $policy = \get_option( 'activitypub_default_feature_policy', ACTIVITYPUB_INTERACTION_POLICY_ME ); + + switch ( $policy ) { + case ACTIVITYPUB_INTERACTION_POLICY_ANYONE: + self::queue_accept( $activity, $user_id ); + break; + + case ACTIVITYPUB_INTERACTION_POLICY_FOLLOWERS: + $follower = Remote_Actors::get_by_uri( object_to_uri( $activity['actor'] ) ); + if ( ! \is_wp_error( $follower ) && Followers::follows( $follower->ID, $user_id ) ) { + self::queue_accept( $activity, $user_id ); + } else { + self::queue_reject( $activity, $user_id ); + $state = false; + } + break; + + case ACTIVITYPUB_INTERACTION_POLICY_ME: + default: + self::queue_reject( $activity, $user_id ); + $state = false; + break; + } + + /** + * Fires after an ActivityPub FeatureRequest activity has been handled. + * + * @param array $activity The ActivityPub activity data. + * @param int[] $user_ids The local user IDs. + * @param bool $state True on accept, false otherwise. + * @param string $policy The active site policy. + */ + \do_action( 'activitypub_handled_feature_request', $activity, (array) $user_ids, $state, $policy ); + } + + /** + * ActivityPub inbox disallowed activity. + * + * @param array $activity The activity array. + * @param int|int[]|null $user_ids The user ID(s). + * @param string $type The activity type. + */ + public static function handle_blocked_request( $activity, $user_ids, $type ) { + if ( ! \in_array( \strtolower( $type ), array( 'featurerequest', 'feature_request' ), true ) ) { + return; + } + + $user_id = \is_array( $user_ids ) ? \reset( $user_ids ) : $user_ids; + self::queue_reject( $activity, $user_id ); + } + + /** + * Send an Accept activity in response to the FeatureRequest, issuing a stamp. + * + * Idempotent: a second call with the same instrument for the same user reuses + * the existing umeta row instead of minting a duplicate stamp. + * + * @param array $activity_object The activity object. + * @param int $user_id The local user ID being featured. + */ + public static function queue_accept( $activity_object, $user_id ) { + if ( ! user_can_activitypub( $user_id ) ) { + $user_id = Actors::BLOG_USER_ID; + } + + $actor = Actors::get_by_id( $user_id ); + if ( \is_wp_error( $actor ) ) { + return; + } + + $activity_object['instrument'] = object_to_uri( $activity_object['instrument'] ); + + // Idempotent stamp creation. + $existing = \get_user_meta( $user_id, '_activitypub_featured_by', false ); + if ( \in_array( $activity_object['instrument'], (array) $existing, true ) ) { + global $wpdb; + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching + $umeta_id = $wpdb->get_var( + $wpdb->prepare( + "SELECT umeta_id FROM {$wpdb->usermeta} WHERE user_id = %d AND meta_key = %s AND meta_value = %s LIMIT 1", + $user_id, + '_activitypub_featured_by', + $activity_object['instrument'] + ) + ); + } else { + $umeta_id = \add_user_meta( $user_id, '_activitypub_featured_by', $activity_object['instrument'] ); + } + + // Send minimal activity object back. + $activity_object = \array_intersect_key( + $activity_object, + array( + 'id' => 1, + 'type' => 1, + 'actor' => 1, + 'object' => 1, + 'instrument' => 1, + ) + ); + + $stamp_url = \add_query_arg( + array( + 'actor' => $user_id, + 'stamp' => $umeta_id, + ), + \home_url( '/' ) + ); + + $activity = new Activity(); + $activity->set_type( 'Accept' ); + $activity->set_actor( $actor->get_id() ); + $activity->set_object( $activity_object ); + $activity->set_result( $stamp_url ); + $activity->add_to( object_to_uri( $activity_object['actor'] ) ); + + add_to_outbox( $activity, null, $user_id, ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE ); + } + + /** + * Send a Reject activity in response to the FeatureRequest. + * + * @param array $activity_object The activity object. + * @param int $user_id The user ID. + */ + public static function queue_reject( $activity_object, $user_id ) { + if ( ! user_can_activitypub( $user_id ) ) { + $user_id = Actors::BLOG_USER_ID; + } + + $actor = Actors::get_by_id( $user_id ); + if ( \is_wp_error( $actor ) ) { + return; + } + + if ( isset( $activity_object['instrument'] ) ) { + $activity_object['instrument'] = object_to_uri( $activity_object['instrument'] ); + } + + // Only send minimal data. + $activity_object = \array_intersect_key( + $activity_object, + array( + 'id' => 1, + 'type' => 1, + 'actor' => 1, + 'object' => 1, + 'instrument' => 1, + ) + ); + + $activity = new Activity(); + $activity->set_type( 'Reject' ); + $activity->set_actor( $actor->get_id() ); + $activity->set_object( $activity_object ); + $activity->add_to( object_to_uri( $activity_object['actor'] ) ); + + add_to_outbox( $activity, null, $user_id, ACTIVITYPUB_CONTENT_VISIBILITY_PRIVATE ); + } + + /** + * Validate the object on incoming FeatureRequest activities. + * + * @param bool $valid The current validation state. + * @param string $param The object parameter name. + * @param \WP_REST_Request $request The request object. + * + * @return bool + */ + public static function validate_object( $valid, $param, $request ) { + $activity = $request->get_json_params(); + + if ( empty( $activity['type'] ) ) { + return false; + } + + if ( 'FeatureRequest' !== $activity['type'] ) { + return $valid; + } + + if ( ! isset( $activity['actor'], $activity['object'], $activity['instrument'] ) ) { + return false; + } + + return $valid; + } +} diff --git a/includes/wp-admin/class-settings-fields.php b/includes/wp-admin/class-settings-fields.php index fd08d34aff..dc975738d0 100644 --- a/includes/wp-admin/class-settings-fields.php +++ b/includes/wp-admin/class-settings-fields.php @@ -122,6 +122,15 @@ public static function register_settings_fields() { array( 'label_for' => 'activitypub_default_quote_policy' ) ); + add_settings_field( + 'activitypub_default_feature_policy', + __( 'Starter Kit requests', 'activitypub' ), + array( self::class, 'render_default_feature_policy_field' ), + 'activitypub_settings', + 'activitypub_activities', + array( 'label_for' => 'activitypub_default_feature_policy' ) + ); + add_settings_field( 'activitypub_use_hashtags', __( 'Hashtags', 'activitypub' ), @@ -399,6 +408,23 @@ public static function render_default_quote_policy_field() { + +
+ +
+ assertSame( 'FeatureAuthorization', $object->get_type() ); + } + + /** + * Test that interactingObject and interactionTarget round-trip. + */ + public function test_interaction_properties_round_trip() { + $object = new Feature_Authorization(); + $object->set_id( 'https://example.com/users/alice/feature-stamps/12' ); + $object->set_interacting_object( 'https://other.example.com/users/bob/featured/23' ); + $object->set_interaction_target( 'https://example.com/users/alice' ); + + $array = $object->to_array(); + + $this->assertSame( 'FeatureAuthorization', $array['type'] ); + $this->assertSame( 'https://other.example.com/users/bob/featured/23', $array['interactingObject'] ); + $this->assertSame( 'https://example.com/users/alice', $array['interactionTarget'] ); + } + + /** + * Test that the JSON-LD context includes the FEP-7aa9 namespace and + * the gts:-namespaced stamp link properties. + */ + public function test_json_ld_context_includes_fep_7aa9() { + $object = new Feature_Authorization(); + $array = $object->to_array(); + + $found = false; + foreach ( (array) $array['@context'] as $entry ) { + if ( is_array( $entry ) && isset( $entry['FeatureAuthorization'] ) ) { + $this->assertSame( 'https://w3id.org/fep/7aa9#FeatureAuthorization', $entry['FeatureAuthorization'] ); + $this->assertSame( 'https://gotosocial.org/ns#', $entry['gts'] ); + $this->assertSame( 'gts:interactingObject', $entry['interactingObject']['@id'] ); + $this->assertSame( '@id', $entry['interactingObject']['@type'] ); + $this->assertSame( 'gts:interactionTarget', $entry['interactionTarget']['@id'] ); + $this->assertSame( '@id', $entry['interactionTarget']['@type'] ); + $found = true; + } + } + $this->assertTrue( $found, 'JSON-LD context must include FeatureAuthorization mapping.' ); + } +} diff --git a/tests/phpunit/tests/includes/class-test-query-feature-stamp.php b/tests/phpunit/tests/includes/class-test-query-feature-stamp.php new file mode 100644 index 0000000000..e892e4125f --- /dev/null +++ b/tests/phpunit/tests/includes/class-test-query-feature-stamp.php @@ -0,0 +1,142 @@ +user->create( array( 'role' => 'author' ) ); + \get_user_by( 'id', self::$user_id )->add_cap( 'activitypub' ); + } + + /** + * Reset the Query singleton between tests. + */ + public function tear_down() { + $instance_property = new \ReflectionProperty( Query::class, 'instance' ); + $instance_property->setAccessible( true ); + $instance_property->setValue( null, null ); + + parent::tear_down(); + } + + /** + * A valid actor+stamp pair resolves to a FeatureAuthorization object. + * + * @covers ::get_activitypub_object + */ + public function test_actor_stamp_resolves_to_feature_authorization() { + $instrument = 'https://other.example.com/users/curator/featured/77'; + $umeta_id = add_user_meta( self::$user_id, '_activitypub_featured_by', $instrument ); + + set_query_var( 'actor', self::$user_id ); + set_query_var( 'stamp', $umeta_id ); + $GLOBALS['wp_query']->queried_object = get_user_by( 'id', self::$user_id ); + $GLOBALS['wp_query']->queried_object_id = self::$user_id; + + $object = Query::get_instance()->get_activitypub_object(); + + $this->assertInstanceOf( Feature_Authorization::class, $object ); + $array = $object->to_array(); + $this->assertSame( 'FeatureAuthorization', $array['type'] ); + $this->assertSame( $instrument, $array['interactingObject'] ); + } + + /** + * A stamp belonging to a different actor is rejected. + * + * @covers ::get_activitypub_object + */ + public function test_cross_actor_stamp_id_is_rejected() { + $other_user = self::factory()->user->create( array( 'role' => 'author' ) ); + \get_user_by( 'id', $other_user )->add_cap( 'activitypub' ); + + $umeta_id = add_user_meta( $other_user, '_activitypub_featured_by', 'https://x/y/1' ); + + set_query_var( 'actor', self::$user_id ); + set_query_var( 'stamp', $umeta_id ); + $GLOBALS['wp_query']->queried_object = get_user_by( 'id', self::$user_id ); + $GLOBALS['wp_query']->queried_object_id = self::$user_id; + + $object = Query::get_instance()->get_activitypub_object(); + $this->assertNotInstanceOf( Feature_Authorization::class, $object ); + } + + /** + * A non-existent stamp ID returns a non-FeatureAuthorization object. + * + * @covers ::get_activitypub_object + */ + public function test_missing_stamp_returns_null_object() { + set_query_var( 'actor', self::$user_id ); + set_query_var( 'stamp', 999999999 ); + $GLOBALS['wp_query']->queried_object = get_user_by( 'id', self::$user_id ); + $GLOBALS['wp_query']->queried_object_id = self::$user_id; + + $object = Query::get_instance()->get_activitypub_object(); + $this->assertNotInstanceOf( Feature_Authorization::class, $object ); + } + + /** + * End-to-end: a stamp URL goes through the same lifecycle as a real + * request (go_to → template_redirect → Query) and produces a + * FeatureAuthorization. This guards against the router reverting to + * the pre-guard state where it would 404 the request before content + * negotiation could resolve the stamp. + * + * @covers ::get_activitypub_object + * @covers \Activitypub\Router::template_redirect + */ + public function test_stamp_url_routes_and_resolves_end_to_end() { + $instrument = 'https://other.example.com/users/curator/featured/integration'; + $umeta_id = add_user_meta( self::$user_id, '_activitypub_featured_by', $instrument ); + + $stamp_url = add_query_arg( + array( + 'actor' => self::$user_id, + 'stamp' => $umeta_id, + ), + home_url( '/' ) + ); + + $this->go_to( $stamp_url ); + + /* + * The pre-guard router would have called set_404() here for any + * non-numeric-username site. Just calling template_redirect without + * exception means the guard fired and let the request through to + * content negotiation. + */ + Router::template_redirect(); + + $object = Query::get_instance()->get_activitypub_object(); + $this->assertInstanceOf( Feature_Authorization::class, $object ); + $this->assertSame( $instrument, $object->to_array()['interactingObject'] ); + } +} diff --git a/tests/phpunit/tests/includes/class-test-router.php b/tests/phpunit/tests/includes/class-test-router.php index 5e686985f8..c71f697fbe 100644 --- a/tests/phpunit/tests/includes/class-test-router.php +++ b/tests/phpunit/tests/includes/class-test-router.php @@ -578,4 +578,28 @@ public function test_filter_adds_custom_taxonomy_to_redirects() { \wp_delete_term( $term_id, 'custom_tax' ); \unregister_taxonomy( 'custom_tax' ); } + + /** + * Test that ?actor=ID&stamp=ID URLs do not 404 in template_redirect. + * + * Stamp URLs use a numeric actor ID that does not match any username, + * so without a guard the router would 404 every FeatureAuthorization + * stamp before content negotiation runs. + * + * @covers ::template_redirect + */ + public function test_template_redirect_passes_through_stamp_urls() { + \set_query_var( 'actor', '2' ); + \set_query_var( 'stamp', '47' ); + + global $wp_query; + $wp_query->is_404 = false; + + Router::template_redirect(); + + $this->assertFalse( $wp_query->is_404(), 'Stamp URLs must not be 404\'d by the actor branch.' ); + + \set_query_var( 'actor', null ); + \set_query_var( 'stamp', null ); + } } diff --git a/tests/phpunit/tests/includes/handler/class-test-feature-request.php b/tests/phpunit/tests/includes/handler/class-test-feature-request.php new file mode 100644 index 0000000000..c28ee593ab --- /dev/null +++ b/tests/phpunit/tests/includes/handler/class-test-feature-request.php @@ -0,0 +1,379 @@ + 'https://remote.example.com/activities/feat-1', + 'type' => 'FeatureRequest', + 'actor' => $actor_uri, + 'object' => Actors::get_by_id( self::$user_id )->get_id(), + 'instrument' => 'https://remote.example.com/users/curator/featured/42', + ); + } + + /** + * Test that validate_object accepts a well-formed FeatureRequest. + * + * @covers ::validate_object + */ + public function test_validate_object_passes_for_valid_feature_request() { + $activity = $this->create_feature_request_activity(); + + $request = new \WP_REST_Request( 'POST', '/inbox' ); + $request->set_body( wp_json_encode( $activity ) ); + $request->set_header( 'Content-Type', 'application/json' ); + + $valid = Feature_Request::validate_object( true, 'object', $request ); + $this->assertTrue( $valid ); + } + + /** + * Test that validate_object rejects a FeatureRequest missing required keys. + * + * @covers ::validate_object + */ + public function test_validate_object_fails_for_missing_instrument() { + $activity = $this->create_feature_request_activity(); + unset( $activity['instrument'] ); + + $request = new \WP_REST_Request( 'POST', '/inbox' ); + $request->set_body( wp_json_encode( $activity ) ); + $request->set_header( 'Content-Type', 'application/json' ); + + $valid = Feature_Request::validate_object( true, 'object', $request ); + $this->assertFalse( $valid ); + } + + /** + * Test that validate_object passes through unrelated activity types unchanged. + * + * @covers ::validate_object + */ + public function test_validate_object_passes_through_other_types() { + $activity = array( + 'type' => 'Follow', + 'actor' => 'https://x', + 'object' => 'https://y', + ); + + $request = new \WP_REST_Request( 'POST', '/inbox' ); + $request->set_body( wp_json_encode( $activity ) ); + $request->set_header( 'Content-Type', 'application/json' ); + + $valid = Feature_Request::validate_object( true, 'object', $request ); + $this->assertTrue( $valid ); + } + + /** + * Test the blocked-request path emits a Reject for FeatureRequest activities. + * + * @covers ::handle_blocked_request + */ + public function test_handle_blocked_request_rejects_feature_request() { + $activity = $this->create_feature_request_activity(); + + Feature_Request::handle_blocked_request( $activity, self::$user_id, 'FeatureRequest' ); + + $outbox = get_posts( + array( + 'post_type' => Outbox::POST_TYPE, + 'post_status' => 'pending', + 'author' => self::$user_id, + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'meta_query' => array( + array( + 'key' => '_activitypub_activity_type', + 'value' => 'Reject', + ), + ), + ) + ); + $this->assertNotEmpty( $outbox, 'Reject activity should be queued for blocked FeatureRequest.' ); + } + + /** + * Test the blocked-request path ignores unrelated activity types. + * + * @covers ::handle_blocked_request + */ + public function test_handle_blocked_request_ignores_other_types() { + $activity = $this->create_feature_request_activity(); + + Feature_Request::handle_blocked_request( $activity, self::$user_id, 'Follow' ); + + $outbox = get_posts( + array( + 'post_type' => Outbox::POST_TYPE, + 'post_status' => 'pending', + 'author' => self::$user_id, + ) + ); + $this->assertEmpty( $outbox, 'No outbox activity should be created for unrelated types.' ); + } + + /** + * Test that the snake_case 'feature_request' type alias is accepted. + * + * The inbox dispatcher snake-cases activity types before firing per-type + * actions, so handle_blocked_request must accept both spellings. + * + * @covers ::handle_blocked_request + */ + public function test_handle_blocked_request_accepts_snake_case_alias() { + $activity = $this->create_feature_request_activity(); + + Feature_Request::handle_blocked_request( $activity, self::$user_id, 'feature_request' ); + + $outbox = get_posts( + array( + 'post_type' => Outbox::POST_TYPE, + 'post_status' => 'pending', + 'author' => self::$user_id, + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'meta_query' => array( + array( + 'key' => '_activitypub_activity_type', + 'value' => 'Reject', + ), + ), + ) + ); + $this->assertNotEmpty( $outbox, 'Reject activity should be queued when dispatcher uses snake_case type.' ); + } + + /** + * Test that queue_reject creates a private Reject activity addressed to the requester. + * + * @covers ::queue_reject + */ + public function test_queue_reject_emits_minimal_private_activity() { + $activity = $this->create_feature_request_activity(); + + Feature_Request::queue_reject( $activity, self::$user_id ); + + $outbox = get_posts( + array( + 'post_type' => Outbox::POST_TYPE, + 'post_status' => 'pending', + 'author' => self::$user_id, + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'meta_query' => array( + array( + 'key' => '_activitypub_activity_type', + 'value' => 'Reject', + ), + ), + ) + ); + $this->assertNotEmpty( $outbox, 'Reject activity should be created.' ); + + $payload = json_decode( $outbox[0]->post_content, true ); + $visibility = get_post_meta( $outbox[0]->ID, 'activitypub_content_visibility', true ); + + $this->assertSame( 'Reject', $payload['type'] ); + $this->assertSame( 'private', $visibility ); + $this->assertContains( $activity['actor'], $payload['to'] ); + + // Object payload should be trimmed to a stable allow-list. + $expected_keys = array( 'id', 'type', 'object', 'actor', 'instrument' ); + $actual_keys = array_keys( $payload['object'] ); + $this->assertEmpty( array_diff( $expected_keys, $actual_keys ), 'All expected keys should be present.' ); + $this->assertEmpty( array_diff( $actual_keys, $expected_keys ), 'No unexpected keys should be present.' ); + } + + /** + * Data provider for policy tests. + * + * @return array Test cases keyed by name. + */ + public function policy_test_data() { + return array( + 'default (me) - reject' => array( '', null, 'Reject' ), + 'me policy - reject' => array( ACTIVITYPUB_INTERACTION_POLICY_ME, null, 'Reject' ), + 'anyone policy - accept' => array( ACTIVITYPUB_INTERACTION_POLICY_ANYONE, null, 'Accept' ), + 'followers policy with follower - accept' => array( + ACTIVITYPUB_INTERACTION_POLICY_FOLLOWERS, + 'add_follower', + 'Accept', + ), + 'followers policy without follower - reject' => array( + ACTIVITYPUB_INTERACTION_POLICY_FOLLOWERS, + null, + 'Reject', + ), + ); + } + + /** + * Test handle_feature_request branches. + * + * @dataProvider policy_test_data + * @covers ::handle_feature_request + * + * @param string $policy Site policy to set, or '' to leave default. + * @param string|null $setup Optional setup callback name. + * @param string $expected_type Activity type expected in the outbox. + */ + public function test_handle_feature_request_policies( $policy, $setup, $expected_type ) { + if ( '' !== $policy ) { + update_option( 'activitypub_default_feature_policy', $policy ); + } else { + delete_option( 'activitypub_default_feature_policy' ); + } + + $activity = $this->create_feature_request_activity(); + $actor = $activity['actor']; + + $pre = function () use ( $actor ) { + return array( + 'id' => $actor, + 'type' => 'Person', + 'inbox' => str_replace( '/users/', '/inbox/', $actor ), + ); + }; + add_filter( 'pre_get_remote_metadata_by_actor', $pre ); + + if ( 'add_follower' === $setup ) { + $follower_id = Followers::add( self::$user_id, $actor ); + $this->assertNotFalse( $follower_id ); + } + + Feature_Request::handle_feature_request( $activity, self::$user_id ); + + $outbox = get_posts( + array( + 'post_type' => Outbox::POST_TYPE, + 'post_status' => 'pending', + 'author' => self::$user_id, + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'meta_query' => array( + array( + 'key' => '_activitypub_activity_type', + 'value' => $expected_type, + ), + ), + ) + ); + $this->assertNotEmpty( $outbox, "{$expected_type} activity should be queued." ); + + remove_filter( 'pre_get_remote_metadata_by_actor', $pre ); + } + + /** + * Test that queue_accept stores a stamp and emits an Accept with the stamp URL. + * + * @covers ::queue_accept + */ + public function test_queue_accept_stores_stamp_and_emits_result() { + $activity = $this->create_feature_request_activity(); + + Feature_Request::queue_accept( $activity, self::$user_id ); + + // Verify usermeta row was created. + $stored = get_user_meta( self::$user_id, '_activitypub_featured_by', false ); + $this->assertContains( $activity['instrument'], $stored, 'Instrument URL should be recorded in user meta.' ); + + // Verify Accept activity in outbox carries a `result` URL containing actor and stamp params. + $outbox = get_posts( + array( + 'post_type' => Outbox::POST_TYPE, + 'post_status' => 'pending', + 'author' => self::$user_id, + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'meta_query' => array( + array( + 'key' => '_activitypub_activity_type', + 'value' => 'Accept', + ), + ), + ) + ); + $this->assertNotEmpty( $outbox ); + $payload = json_decode( $outbox[0]->post_content, true ); + $this->assertSame( 'Accept', $payload['type'] ); + $this->assertNotEmpty( $payload['result'] ); + $this->assertStringContainsString( 'actor=' . self::$user_id, $payload['result'] ); + $this->assertStringContainsString( 'stamp=', $payload['result'] ); + } + + /** + * Test that queue_accept is idempotent: calling it twice with the same instrument + * reuses the existing usermeta row and does not duplicate stamps. + * + * @covers ::queue_accept + */ + public function test_queue_accept_idempotent() { + $activity = $this->create_feature_request_activity(); + + Feature_Request::queue_accept( $activity, self::$user_id ); + Feature_Request::queue_accept( $activity, self::$user_id ); + + $stored = get_user_meta( self::$user_id, '_activitypub_featured_by', false ); + $this->assertCount( 1, $stored, 'Duplicate FeatureRequests for the same instrument must not produce multiple stamps.' ); + } + + /** + * Test that an unresolvable target (no local actor matches the activity object) + * still produces a Reject so the curator gets a definitive answer. + * + * @covers ::handle_feature_request + */ + public function test_handle_feature_request_rejects_unresolvable_target() { + update_option( 'activitypub_default_feature_policy', ACTIVITYPUB_INTERACTION_POLICY_ANYONE ); + + $activity = $this->create_feature_request_activity(); + $activity['object'] = 'https://this-host-does-not-host.example/users/ghost'; + + Feature_Request::handle_feature_request( $activity, self::$user_id ); + + $outbox = get_posts( + array( + 'post_type' => Outbox::POST_TYPE, + 'post_status' => 'pending', + 'author' => self::$user_id, + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'meta_query' => array( + array( + 'key' => '_activitypub_activity_type', + 'value' => 'Reject', + ), + ), + ) + ); + $this->assertNotEmpty( $outbox, 'Unresolvable target should still produce a Reject activity.' ); + } +} diff --git a/tests/phpunit/tests/includes/model/class-test-interaction-policy.php b/tests/phpunit/tests/includes/model/class-test-interaction-policy.php new file mode 100644 index 0000000000..c9deaba265 --- /dev/null +++ b/tests/phpunit/tests/includes/model/class-test-interaction-policy.php @@ -0,0 +1,101 @@ +user->create( array( 'role' => 'author' ) ); + \get_user_by( 'id', self::$user_id )->add_cap( 'activitypub' ); + } + + /** + * Reset the option after each test so other tests are unaffected. + */ + public function tear_down() { + delete_option( 'activitypub_default_feature_policy' ); + parent::tear_down(); + } + + /** + * Default policy is ME (denied). The actor MUST advertise its own id as + * the only automatic-approval target — explicit denial per FEP-7aa9. + */ + public function test_user_actor_emits_canfeature_me_by_default() { + $user = new User( self::$user_id ); + $policy = $user->get_interaction_policy(); + + $this->assertIsArray( $policy ); + $this->assertArrayHasKey( 'canFeature', $policy ); + $this->assertSame( array( $user->get_id() ), $policy['canFeature']['automaticApproval'] ); + } + + /** + * `anyone` policy emits the AS2 Public collection. + */ + public function test_user_actor_emits_canfeature_anyone_when_opted_in() { + update_option( 'activitypub_default_feature_policy', ACTIVITYPUB_INTERACTION_POLICY_ANYONE ); + + $user = new User( self::$user_id ); + $policy = $user->get_interaction_policy(); + + $this->assertSame( + array( 'https://www.w3.org/ns/activitystreams#Public' ), + $policy['canFeature']['automaticApproval'] + ); + } + + /** + * `followers` policy emits the actor's followers collection URL. + */ + public function test_user_actor_emits_canfeature_followers() { + update_option( 'activitypub_default_feature_policy', ACTIVITYPUB_INTERACTION_POLICY_FOLLOWERS ); + + $user = new User( self::$user_id ); + $policy = $user->get_interaction_policy(); + + $this->assertSame( + array( $user->get_followers() ), + $policy['canFeature']['automaticApproval'] + ); + } + + /** + * Blog actor inherits the same canFeature behavior, including default-deny. + */ + public function test_blog_actor_emits_canfeature() { + $blog = new Blog(); + $policy = $blog->get_interaction_policy(); + + $this->assertIsArray( $policy ); + $this->assertArrayHasKey( 'canFeature', $policy ); + $this->assertSame( + array( $blog->get_id() ), + $policy['canFeature']['automaticApproval'], + 'Blog actor must default to explicit denial (its own id as the only approved target).' + ); + } +}