diff --git a/.github/changelog/2297-from-description b/.github/changelog/2297-from-description new file mode 100644 index 000000000..6f807b475 --- /dev/null +++ b/.github/changelog/2297-from-description @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Added support for FEP-8fcf follower synchronization, improving data consistency across servers with new sync headers, digest checks, and reconciliation tasks. diff --git a/FEDERATION.md b/FEDERATION.md index adcbcb4c3..551c64ba8 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -24,6 +24,7 @@ The WordPress plugin largely follows ActivityPub's server-to-server specificatio - [FEP-844e: Capability discovery](https://codeberg.org/fediverse/fep/src/branch/main/fep/844e/fep-844e.md) - [FEP-044f: Consent-respecting quote posts](https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md) - [FEP-3b86: Activity Intents](https://codeberg.org/fediverse/fep/src/branch/main/fep/3b86/fep-3b86.md) +- [FEP-8fcf: Followers collection synchronization across servers](https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md) Partially supported FEPs diff --git a/includes/class-handler.php b/includes/class-handler.php index 2ef9250fd..4db0baad3 100644 --- a/includes/class-handler.php +++ b/includes/class-handler.php @@ -9,6 +9,7 @@ use Activitypub\Handler\Accept; use Activitypub\Handler\Announce; +use Activitypub\Handler\Collection_Sync; use Activitypub\Handler\Create; use Activitypub\Handler\Delete; use Activitypub\Handler\Follow; @@ -37,6 +38,7 @@ public static function init() { public static function register_handlers() { Accept::init(); Announce::init(); + Collection_Sync::init(); Create::init(); Delete::init(); Follow::init(); diff --git a/includes/class-http.php b/includes/class-http.php index 2f4f62ae9..9e2d64cfd 100644 --- a/includes/class-http.php +++ b/includes/class-http.php @@ -53,6 +53,7 @@ public static function post( $url, $body, $user_id ) { 'body' => $body, 'key_id' => \json_decode( $body )->actor . '#main-key', 'private_key' => Actors::get_private_key( $user_id ), + 'user_id' => $user_id, ); $response = \wp_safe_remote_post( $url, $args ); diff --git a/includes/class-scheduler.php b/includes/class-scheduler.php index a50a12cf8..4c4ef73b7 100644 --- a/includes/class-scheduler.php +++ b/includes/class-scheduler.php @@ -14,6 +14,7 @@ use Activitypub\Collection\Outbox; use Activitypub\Collection\Remote_Actors; use Activitypub\Scheduler\Actor; +use Activitypub\Scheduler\Collection_Sync; use Activitypub\Scheduler\Comment; use Activitypub\Scheduler\Post; @@ -60,6 +61,7 @@ public static function init() { public static function register_schedulers() { Post::init(); Actor::init(); + Collection_Sync::init(); Comment::init(); /** diff --git a/includes/class-signature.php b/includes/class-signature.php index 2087f31be..c3759a653 100644 --- a/includes/class-signature.php +++ b/includes/class-signature.php @@ -465,4 +465,97 @@ public static function generate_digest( $body ) { $digest = \base64_encode( \hash( 'sha256', $body, true ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode return "SHA-256=$digest"; } + + /** + * Compute the collection digest for a specific instance. + * + * Implements FEP-8fcf: Followers collection synchronization. + * The digest is created by XORing together the individual SHA256 digests + * of each follower's ID. + * + * @see https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md + * + * @param array $collection The user ID whose followers to compute. + * + * @return string|false The hex-encoded digest, or false if no followers. + */ + public static function get_collection_digest( $collection ) { + if ( empty( $collection ) || ! is_array( $collection ) ) { + return false; + } + + // Initialize with zeros (64 hex chars = 32 bytes = 256 bits). + $digest = str_repeat( '0', 64 ); + + foreach ( $collection as $item ) { + // Compute SHA256 hash of the follower ID. + $hash = hash( 'sha256', $item ); + + // XOR the hash with the running digest. + $digest = self::xor_hex_strings( $digest, $hash ); + } + + return $digest; + } + + /** + * XOR two hexadecimal strings. + * + * Used for FEP-8fcf digest computation. + * + * @param string $hex1 First hex string. + * @param string $hex2 Second hex string. + * + * @return string The XORed result as a hex string. + */ + public static function xor_hex_strings( $hex1, $hex2 ) { + $result = ''; + + // Ensure both strings are the same length (should be 64 chars for SHA256). + $length = \max( \strlen( $hex1 ), \strlen( $hex2 ) ); + $hex1 = \str_pad( $hex1, $length, '0', STR_PAD_LEFT ); + $hex2 = \str_pad( $hex2, $length, '0', STR_PAD_LEFT ); + + // XOR each pair of hex digits. + for ( $i = 0; $i < $length; $i += 2 ) { + $byte1 = \hexdec( \substr( $hex1, $i, 2 ) ); + $byte2 = \hexdec( \substr( $hex2, $i, 2 ) ); + $result .= \str_pad( \dechex( $byte1 ^ $byte2 ), 2, '0', STR_PAD_LEFT ); + } + + return $result; + } + + /** + * Parse a Collection-Synchronization header (FEP-8fcf). + * + * Parses the signature-style format used by the Collection-Synchronization header. + * + * @see https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md + * + * @param string $header The header value. + * + * @return array|false Array with parsed parameters (collectionId, url, digest), or false on failure. + */ + public static function parse_collection_sync_header( $header ) { + if ( empty( $header ) ) { + return false; + } + + // Parse the signature-style format: key="value", key="value". + $params = array(); + + if ( \preg_match_all( '/(\w+)="([^"]*)"/', $header, $matches, PREG_SET_ORDER ) ) { + foreach ( $matches as $match ) { + $params[ $match[1] ] = $match[2]; + } + } + + // Validate required fields for FEP-8fcf. + if ( empty( $params['collectionId'] ) || empty( $params['url'] ) || empty( $params['digest'] ) ) { + return false; + } + + return $params; + } } diff --git a/includes/collection/class-followers.php b/includes/collection/class-followers.php index 69ebddc6b..3dcf83355 100644 --- a/includes/collection/class-followers.php +++ b/includes/collection/class-followers.php @@ -7,9 +7,11 @@ namespace Activitypub\Collection; +use Activitypub\Signature; use Activitypub\Tombstone; use function Activitypub\get_remote_metadata_by_actor; +use function Activitypub\get_rest_url_by_path; /** * ActivityPub Followers Collection. @@ -567,4 +569,108 @@ public static function remove_blocked_actors( $value, $type, $user_id ) { self::remove( $actor_id, $user_id ); } + + /** + * Compute the partial follower collection digest for a specific instance. + * + * Implements FEP-8fcf: Followers collection synchronization. + * This is a convenience wrapper that filters followers by authority and then + * computes the digest using the standard FEP-8fcf algorithm. + * + * The digest is created by XORing together the individual SHA256 digests + * of each follower's ID. + * + * @see https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md + * @see Signature::get_collection_digest() for the core digest algorithm + * + * @param int $user_id The user ID whose followers to compute. + * @param string $authority The URI authority (scheme + host) to filter by. + * + * @return string|false The hex-encoded digest, or false if no followers. + */ + public static function compute_partial_digest( $user_id, $authority ) { + // Get followers filtered by authority. + $followers = self::get_by_authority( $user_id, $authority ); + $follower_ids = \wp_list_pluck( $followers, 'guid' ); + + // Delegate to the core digest computation algorithm. + return Signature::get_collection_digest( $follower_ids ); + } + + /** + * Get partial followers collection for a specific instance. + * + * Returns only followers whose ID shares the specified URI authority. + * Used for FEP-8fcf synchronization. + * + * @param int $user_id The user ID whose followers to get. + * @param string $authority The URI authority (scheme + host) to filter by. + * + * @return \WP_Post[] Array of WP_Post objects. + */ + public static function get_by_authority( $user_id, $authority ) { + $posts = new \WP_Query( + array( + 'post_type' => Remote_Actors::POST_TYPE, + 'posts_per_page' => -1, + 'orderby' => 'ID', + 'order' => 'DESC', + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'meta_query' => array( + 'relation' => 'AND', + array( + 'key' => self::FOLLOWER_META_KEY, + 'value' => $user_id, + ), + array( + 'key' => '_activitypub_inbox', + 'compare' => 'LIKE', + 'value' => $authority, + ), + ), + ) + ); + + return $posts->posts ?? array(); + } + + /** + * Generate the Collection-Synchronization header value for FEP-8fcf. + * + * @param int $user_id The user ID whose followers collection to sync. + * @param string $authority The authority of the receiving instance. + * + * @return string|false The header value, or false if cannot generate. + */ + public static function generate_sync_header( $user_id, $authority ) { + $followers = self::get_by_authority( $user_id, $authority ); + $followers = \wp_list_pluck( $followers, 'guid' ); + + // Compute the digest for this specific authority. + $digest = Signature::get_collection_digest( $followers ); + + if ( ! $digest ) { + return false; + } + + // Build the collection ID (followers collection URL). + $collection_id = get_rest_url_by_path( sprintf( 'actors/%d/followers', $user_id ) ); + + // Build the partial followers URL. + $url = get_rest_url_by_path( + sprintf( + 'actors/%d/followers/sync?authority=%s', + $user_id, + rawurlencode( $authority ) + ) + ); + + // Format as per FEP-8fcf (similar to HTTP Signatures format). + return sprintf( + 'collectionId="%s", url="%s", digest="%s"', + $collection_id, + $url, + $digest + ); + } } diff --git a/includes/collection/class-following.php b/includes/collection/class-following.php index c87207d8c..2b603d633 100644 --- a/includes/collection/class-following.php +++ b/includes/collection/class-following.php @@ -347,6 +347,44 @@ public static function query_all( $user_id, $number = -1, $page = null, $args = return self::query( $user_id, $number, $page, $args ); } + /** + * Get partial followers collection for a specific instance. + * + * Returns only followers whose ID shares the specified URI authority. + * Used for FEP-8fcf synchronization. + * + * @param int $user_id The user ID whose followers to get. + * @param string $authority The URI authority (scheme + host) to filter by. + * @param string $state The following state to filter by (accepted or pending). Default is accepted. + * + * @return array Array of follower URLs. + */ + public static function get_by_authority( $user_id, $authority, $state = self::FOLLOWING_META_KEY ) { + $posts = new \WP_Query( + array( + 'post_type' => Remote_Actors::POST_TYPE, + 'posts_per_page' => -1, + 'orderby' => 'ID', + 'order' => 'DESC', + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'meta_query' => array( + 'relation' => 'AND', + array( + 'key' => $state, + 'value' => $user_id, + ), + array( + 'key' => '_activitypub_inbox', + 'compare' => 'LIKE', + 'value' => $authority, + ), + ), + ) + ); + + return $posts->posts ?? array(); + } + /** * Get all followings of a given user. * diff --git a/includes/collection/class-remote-actors.php b/includes/collection/class-remote-actors.php index 09bb62c37..ad169dfee 100644 --- a/includes/collection/class-remote-actors.php +++ b/includes/collection/class-remote-actors.php @@ -514,7 +514,7 @@ public static function normalize_identifier( $actor ) { // If it's an email-like webfinger address, resolve it. if ( \filter_var( $actor, FILTER_VALIDATE_EMAIL ) ) { - $resolved = \Activitypub\Webfinger::resolve( $actor ); + $resolved = Webfinger::resolve( $actor ); return \is_wp_error( $resolved ) ? null : object_to_uri( $resolved ); } diff --git a/includes/functions.php b/includes/functions.php index f2672ce9f..a853cccae 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -1811,3 +1811,20 @@ function extract_name_from_uri( $uri ) { return $name; } + +/** + * Get the authority (scheme + host) from a URL. + * + * @param string $url The URL to parse. + * + * @return string|false The authority, or false on failure. + */ +function get_url_authority( $url ) { + $parsed = wp_parse_url( $url ); + + if ( ! $parsed || empty( $parsed['scheme'] ) || empty( $parsed['host'] ) ) { + return false; + } + + return $parsed['scheme'] . '://' . $parsed['host']; +} diff --git a/includes/handler/class-collection-sync.php b/includes/handler/class-collection-sync.php new file mode 100644 index 000000000..54e95ef1f --- /dev/null +++ b/includes/handler/class-collection-sync.php @@ -0,0 +1,223 @@ +get_followers(); + + if ( \is_wp_error( $expected_collection ) ) { + return false; + } + + if ( trailingslashit( $params['collectionId'] ) !== trailingslashit( $expected_collection ) ) { + return false; + } + + // Build authorities for comparison. + $collection_authority = get_url_authority( $params['collectionId'] ); + $url_authority = get_url_authority( $params['url'] ); + + return $collection_authority === $url_authority; + } + + /** + * Get the frequency for Collection-Synchronization headers. + * + * @return int Frequency in seconds. + */ + private static function get_frequency() { + /** + * Filter the frequency of Collection-Synchronization headers sent to a given authority. + * + * @param int $frequency The frequency in seconds. Default is one week. + * @param int $user_id The local user ID. + * @param string $inbox_authority The inbox authority. + */ + return \apply_filters( 'activitypub_collection_sync_frequency', WEEK_IN_SECONDS ); + } +} diff --git a/includes/rest/class-followers-controller.php b/includes/rest/class-followers-controller.php index fc2b43440..4aab6a8a9 100644 --- a/includes/rest/class-followers-controller.php +++ b/includes/rest/class-followers-controller.php @@ -75,6 +75,54 @@ public function register_routes() { 'schema' => array( $this, 'get_item_schema' ), ) ); + + // FEP-8fcf: Partial followers collection for synchronization. + \register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/followers/sync', + array( + 'args' => array( + 'user_id' => array( + 'description' => 'The ID of the actor.', + 'type' => 'integer', + 'required' => true, + 'validate_callback' => array( $this, 'validate_user_id' ), + ), + ), + array( + 'methods' => \WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_partial_followers' ), + 'permission_callback' => array( 'Activitypub\Rest\Server', 'verify_signature' ), + 'args' => array( + 'authority' => array( + 'description' => 'The host to filter followers by.', + 'type' => 'string', + 'format' => 'uri', + 'pattern' => '^https?://[^/]+$', + 'required' => true, + ), + 'page' => array( + 'description' => 'Current page of the collection.', + 'type' => 'integer', + 'minimum' => 1, + // No default so we can differentiate between Collection and CollectionPage requests. + ), + 'per_page' => array( + 'description' => 'Maximum number of items to be returned in result set.', + 'type' => 'integer', + 'default' => 20, + 'minimum' => 1, + ), + 'order' => array( + 'description' => 'Order sort attribute ascending or descending.', + 'type' => 'string', + 'default' => 'desc', + 'enum' => array( 'asc', 'desc' ), + ), + ), + ), + ) + ); } /** @@ -139,6 +187,47 @@ function ( $item ) use ( $context ) { return $response; } + /** + * Retrieves partial followers list for FEP-8fcf synchronization. + * + * Returns only followers whose ID shares the specified URI authority. + * + * @param \WP_REST_Request $request Full details about the request. + * @return \WP_REST_Response|\WP_Error Response object on success, or WP_Error object on failure. + */ + public function get_partial_followers( $request ) { + $user_id = $request->get_param( 'user_id' ); + $authority = $request->get_param( 'authority' ); + + // Get partial followers filtered by authority. + $followers = Followers::get_by_authority( $user_id, $authority ); + $followers = \wp_list_pluck( $followers, 'guid' ); + + $response = array( + 'id' => get_rest_url_by_path( + \sprintf( + 'actors/%d/followers/sync?authority=%s', + $user_id, + rawurlencode( $authority ) + ) + ), + 'type' => 'OrderedCollection', + 'totalItems' => count( $followers ), + 'orderedItems' => $followers, + ); + + $response = $this->prepare_collection_response( $response, $request ); + + if ( \is_wp_error( $response ) ) { + return $response; + } + + $response = \rest_ensure_response( $response ); + $response->header( 'Content-Type', 'application/activity+json; charset=' . \get_option( 'blog_charset' ) ); + + return $response; + } + /** * Retrieves the followers schema, conforming to JSON Schema. * diff --git a/includes/scheduler/class-collection-sync.php b/includes/scheduler/class-collection-sync.php new file mode 100644 index 000000000..3b946539d --- /dev/null +++ b/includes/scheduler/class-collection-sync.php @@ -0,0 +1,102 @@ +guid, $remote_followers, true ); + if ( false === $key ) { + Following::reject( $following, $user_id ); + } else { + unset( $remote_followers[ $key ] ); + } + } + + $remote_followers = array_values( $remote_followers ); // Reindex. + + $pending = Following::get_by_authority( $user_id, $home_authority, Following::PENDING_META_KEY ); + foreach ( $pending as $following ) { + $key = array_search( $following->guid, $remote_followers, true ); + if ( false === $key ) { + Following::reject( $following, $user_id ); + } else { + Following::accept( $following, $user_id ); + unset( $remote_followers[ $key ] ); + } + } + + /** + * Action triggered after reconciliation is complete. + * + * @param int $user_id The local user ID that triggered the reconciliation. + * @param string $actor_url The remote actor URL. + */ + \do_action( 'activitypub_followers_sync_reconciled', $user_id, $actor_url ); + } +} diff --git a/includes/signature/class-http-message-signature.php b/includes/signature/class-http-message-signature.php index 956df92db..34142e3ed 100644 --- a/includes/signature/class-http-message-signature.php +++ b/includes/signature/class-http-message-signature.php @@ -91,11 +91,16 @@ class Http_Message_Signature implements Http_Signature { */ public function sign( $args, $url ) { // Standard components to sign. - $components = array( + $components = array( '"@method"' => \strtoupper( $args['method'] ), '"@target-uri"' => $url, '"@authority"' => \wp_parse_url( $url, PHP_URL_HOST ), ); + + if ( isset( $args['headers']['Collection-Synchronization'] ) ) { + $components['"collection-synchronization"'] = $args['headers']['Collection-Synchronization']; + } + $identifiers = \array_keys( $components ); // Add digest if provided. diff --git a/includes/signature/class-http-signature-draft.php b/includes/signature/class-http-signature-draft.php index 7c934ace8..54f5118e3 100644 --- a/includes/signature/class-http-signature-draft.php +++ b/includes/signature/class-http-signature-draft.php @@ -49,16 +49,27 @@ public function sign( $args, $url ) { $http_method = \strtolower( $args['method'] ); $date = $args['headers']['Date']; + $signed_parts = array( + sprintf( '(request-target): %s %s', $http_method, $path ), + sprintf( 'host: %s', $host ), + sprintf( 'date: %s', $date ), + ); + $headers_list = array( '(request-target)', 'host', 'date' ); + if ( isset( $args['body'] ) ) { $args['headers']['Digest'] = $this->generate_digest( $args['body'] ); + $signed_parts[] = sprintf( 'digest: %s', $args['headers']['Digest'] ); + $headers_list[] = 'digest'; + } - $signed_string = "(request-target): $http_method $path\nhost: $host\ndate: $date\ndigest: {$args['headers']['Digest']}"; - $headers_list = '(request-target) host date digest'; - } else { - $signed_string = "(request-target): $http_method $path\nhost: $host\ndate: $date"; - $headers_list = '(request-target) host date'; + if ( isset( $args['headers']['Collection-Synchronization'] ) ) { + $signed_parts[] = sprintf( 'collection-synchronization: %s', $args['headers']['Collection-Synchronization'] ); + $headers_list[] = 'collection-synchronization'; } + $signed_string = implode( "\n", $signed_parts ); + $headers_list = implode( ' ', $headers_list ); + $signature = null; \openssl_sign( $signed_string, $signature, $args['private_key'], \OPENSSL_ALGO_SHA256 ); $signature = \base64_encode( $signature ); diff --git a/tests/e2e/config/global-setup.js b/tests/e2e/config/global-setup.js index 5112a96d5..bbab9ff9a 100644 --- a/tests/e2e/config/global-setup.js +++ b/tests/e2e/config/global-setup.js @@ -31,7 +31,6 @@ async function globalSetup( config ) { // Reset the test environment before running the tests. await Promise.all( [ - requestUtils.activateTheme( 'twentytwentyfour' ), requestUtils.deleteAllPosts(), requestUtils.deleteAllBlocks(), requestUtils.resetPreferences(), diff --git a/tests/e2e/playwright.config.js b/tests/e2e/playwright.config.js index fd367b749..2d171af17 100644 --- a/tests/e2e/playwright.config.js +++ b/tests/e2e/playwright.config.js @@ -15,7 +15,6 @@ process.env.STORAGE_STATE_PATH ??= path.join( process.env.WP_ARTIFACTS_PATH, 'st const config = defineConfig( { ...baseConfig, globalSetup: require.resolve( './config/global-setup.js' ), - testDir: './specs', webServer: { ...baseConfig.webServer, command: 'npm run env-start', diff --git a/tests/e2e/specs/includes/rest/actors-controller.test.js b/tests/e2e/specs/includes/rest/actors-controller.test.js index a59a46d96..38ccd8898 100644 --- a/tests/e2e/specs/includes/rest/actors-controller.test.js +++ b/tests/e2e/specs/includes/rest/actors-controller.test.js @@ -10,7 +10,7 @@ test.describe( 'ActivityPub Actors REST API', () => { test.beforeAll( async ( { requestUtils } ) => { // Use the default test user testUserId = 1; - actorEndpoint = `/activitypub/1.0/users/${ testUserId }`; + actorEndpoint = `/activitypub/1.0/actors/${ testUserId }`; } ); test( 'should return 200 status code for actor endpoint', async ( { requestUtils } ) => { diff --git a/tests/e2e/specs/includes/rest/followers-controller.test.js b/tests/e2e/specs/includes/rest/followers-controller.test.js index bb80fd553..a29ed7bb1 100644 --- a/tests/e2e/specs/includes/rest/followers-controller.test.js +++ b/tests/e2e/specs/includes/rest/followers-controller.test.js @@ -125,4 +125,288 @@ test.describe( 'ActivityPub Followers Endpoint', () => { // Verify proper typing expect( data.type ).toBe( 'OrderedCollection' ); } ); + + test.describe( 'Followers Collection Endpoint', () => { + test( 'should return Collection-Synchronization header on followers collection request', async ( { + requestUtils, + } ) => { + await requestUtils.setupRest(); + + try { + // Request followers collection with proper headers + const response = await requestUtils.rest( { + path: '/activitypub/1.0/actors/1/followers', + } ); + + // Check if response has expected structure + expect( response ).toHaveProperty( '@context' ); + expect( response ).toHaveProperty( 'type', 'OrderedCollection' ); + expect( response ).toHaveProperty( 'totalItems' ); + expect( response ).toHaveProperty( 'id' ); + } catch ( error ) { + // Log error for debugging + console.error( 'Followers collection request failed:', error.message ); + throw error; + } + } ); + + test( 'should include proper pagination links in followers collection', async ( { requestUtils } ) => { + await requestUtils.setupRest(); + + try { + const response = await requestUtils.rest( { + path: '/activitypub/1.0/actors/1/followers', + } ); + + // Collection should have first and last links + expect( response ).toHaveProperty( 'id' ); + expect( response.id ).toContain( '/activitypub/1.0/actors/1/followers' ); + } catch ( error ) { + console.error( 'Pagination test failed:', error.message ); + throw error; + } + } ); + } ); + + test.describe( 'Partial Followers Sync Endpoint', () => { + test( 'should accept authority parameter for partial followers', async ( { requestUtils } ) => { + await requestUtils.setupRest(); + + const testAuthority = 'https://example.com'; + + const response = await requestUtils.rest( { + path: `/activitypub/1.0/actors/1/followers/sync`, + params: { authority: testAuthority }, + } ); + expect( response ).toHaveProperty( 'type', 'OrderedCollection' ); + expect( response ).toHaveProperty( 'totalItems' ); + expect( response ).toHaveProperty( 'orderedItems' ); + + // Verify the collection ID includes the authority parameter + expect( response.id ).toContain( 'authority=' ); + expect( response.id ).toContain( encodeURIComponent( testAuthority ) ); + + // orderedItems should be an array + expect( Array.isArray( response.orderedItems ) ).toBe( true ); + } ); + + test( 'should reject invalid authority format', async ( { requestUtils } ) => { + await requestUtils.setupRest(); + + // Test with invalid authority (no protocol) + const invalidAuthority = 'example.com'; + + try { + await requestUtils.rest( { + path: `/activitypub/1.0/actors/1/followers/sync`, + params: { authority: invalidAuthority }, + } ); + // If no error is thrown, fail the test + expect( false ).toBe( true ); + } catch ( error ) { + // Should return 400 Bad Request for invalid authority (or 404 if endpoint doesn't exist) + expect( error.status || error.code ).toBeGreaterThanOrEqual( 400 ); + } + } ); + + test( 'should require authority parameter', async ( { requestUtils } ) => { + await requestUtils.setupRest(); + + try { + await requestUtils.rest( { + path: '/activitypub/1.0/actors/1/followers/sync', + } ); + // If no error is thrown, fail the test + expect( false ).toBe( true ); + } catch ( error ) { + // Should return 400 when authority is missing (or 404 if endpoint doesn't exist) + expect( error.status || error.code ).toBeGreaterThanOrEqual( 400 ); + } + } ); + + test( 'should return empty collection for authority with no followers', async ( { requestUtils } ) => { + await requestUtils.setupRest(); + + // Use an authority that definitely has no followers + const testAuthority = 'https://non-existent-instance.test'; + + const response = await requestUtils.rest( { + path: `/activitypub/1.0/actors/1/followers/sync`, + params: { authority: testAuthority }, + } ); + + expect( response.type ).toBe( 'OrderedCollection' ); + expect( response.totalItems ).toBe( 0 ); + expect( response.orderedItems ).toEqual( [] ); + } ); + } ); + + test.describe( 'Collection Response Format', () => { + test( 'should return valid ActivityStreams OrderedCollection', async ( { requestUtils } ) => { + await requestUtils.setupRest(); + + const testAuthority = 'https://mastodon.social'; + + const response = await requestUtils.rest( { + path: `/activitypub/1.0/actors/1/followers/sync`, + params: { authority: testAuthority }, + } ); + + // Validate ActivityStreams OrderedCollection structure + expect( response ).toHaveProperty( '@context' ); + expect( response ).toHaveProperty( 'id' ); + expect( response ).toHaveProperty( 'type', 'OrderedCollection' ); + expect( response ).toHaveProperty( 'totalItems' ); + expect( typeof response.totalItems ).toBe( 'number' ); + expect( response ).toHaveProperty( 'orderedItems' ); + expect( Array.isArray( response.orderedItems ) ).toBe( true ); + } ); + + test( 'should return proper Content-Type header', async ( { requestUtils } ) => { + await requestUtils.setupRest(); + + const testAuthority = 'https://example.com'; + + const response = await requestUtils.rest( { + path: `/activitypub/1.0/actors/1/followers/sync`, + params: { authority: testAuthority }, + } ); + + // If we got data back, the content type was acceptable + expect( response ).toBeDefined(); + expect( response ).toHaveProperty( 'type' ); + } ); + } ); + + test.describe( 'Multiple Authorities', () => { + test( 'should handle different authority formats correctly', async ( { requestUtils } ) => { + await requestUtils.setupRest(); + + const authorities = [ + 'https://mastodon.social', + 'https://mastodon.social:443', + 'http://localhost:3000', + 'https://subdomain.example.com', + ]; + + for ( const authority of authorities ) { + const response = await requestUtils.rest( { + path: `/activitypub/1.0/actors/1/followers/sync`, + params: { authority }, + } ); + + expect( response.type ).toBe( 'OrderedCollection' ); + expect( response ).toHaveProperty( 'totalItems' ); + expect( Array.isArray( response.orderedItems ) ).toBe( true ); + } + } ); + } ); + + test.describe( 'Error Handling', () => { + test( 'should return 404 for non-existent actor', async ( { requestUtils } ) => { + await requestUtils.setupRest(); + + const testAuthority = 'https://example.com'; + + try { + await requestUtils.rest( { + path: `/activitypub/1.0/actors/99999/followers/sync`, + params: { authority: testAuthority }, + } ); + // If no error is thrown, fail the test + expect( false ).toBe( true ); + } catch ( error ) { + // Should return 404 or 400 for non-existent user + expect( error.status || error.code ).toBeGreaterThanOrEqual( 400 ); + } + } ); + + test( 'should handle malformed authority gracefully', async ( { requestUtils } ) => { + await requestUtils.setupRest(); + + const malformedAuthorities = [ + 'not-a-url', + 'ftp://invalid-protocol.com', + 'https://', + '://no-protocol.com', + ]; + + for ( const authority of malformedAuthorities ) { + try { + await requestUtils.rest( { + path: `/activitypub/1.0/actors/1/followers/sync`, + params: { authority }, + } ); + // If no error is thrown, fail the test + expect( false ).toBe( true ); + } catch ( error ) { + // Should return 400 for invalid authority format + expect( error.status || error.code ).toBe( 400 ); + } + } + } ); + } ); + + test.describe( 'Response Consistency', () => { + test( 'should return consistent results for same authority', async ( { requestUtils } ) => { + await requestUtils.setupRest(); + + const testAuthority = 'https://example.com'; + + // Make two requests to the same endpoint + const response1 = await requestUtils.rest( { + path: `/activitypub/1.0/actors/1/followers/sync`, + params: { authority: testAuthority }, + } ); + + const response2 = await requestUtils.rest( { + path: `/activitypub/1.0/actors/1/followers/sync`, + params: { authority: testAuthority }, + } ); + + // Results should be consistent + expect( response1.totalItems ).toBe( response2.totalItems ); + expect( response1.orderedItems ).toEqual( response2.orderedItems ); + } ); + + test( 'should filter followers correctly by authority', async ( { requestUtils } ) => { + await requestUtils.setupRest(); + + const authority1 = 'https://mastodon.social'; + const authority2 = 'https://pixelfed.social'; + + const response1 = await requestUtils.rest( { + path: `/activitypub/1.0/actors/1/followers/sync`, + params: { authority: authority1 }, + } ); + + const response2 = await requestUtils.rest( { + path: `/activitypub/1.0/actors/1/followers/sync`, + params: { authority: authority2 }, + } ); + + // Both should return valid collections (even if empty) + expect( response1.type ).toBe( 'OrderedCollection' ); + expect( response2.type ).toBe( 'OrderedCollection' ); + + // Each should have their own totalItems count + expect( typeof response1.totalItems ).toBe( 'number' ); + expect( typeof response2.totalItems ).toBe( 'number' ); + + // If there are followers, they should all match the authority + if ( response1.orderedItems.length > 0 ) { + response1.orderedItems.forEach( ( follower ) => { + expect( typeof follower ).toBe( 'string' ); + expect( follower ).toContain( 'https://' ); + } ); + } + + if ( response2.orderedItems.length > 0 ) { + response2.orderedItems.forEach( ( follower ) => { + expect( typeof follower ).toBe( 'string' ); + expect( follower ).toContain( 'https://' ); + } ); + } + } ); + } ); } ); diff --git a/tests/e2e/specs/includes/rest/following-controller.test.js b/tests/e2e/specs/includes/rest/following-controller.test.js index c317553f2..78d241203 100644 --- a/tests/e2e/specs/includes/rest/following-controller.test.js +++ b/tests/e2e/specs/includes/rest/following-controller.test.js @@ -10,7 +10,7 @@ test.describe( 'ActivityPub Following Collection REST API', () => { test.beforeAll( async ( { requestUtils } ) => { // Use the default test user testUserId = 1; - followingEndpoint = `/activitypub/1.0/users/${ testUserId }/following`; + followingEndpoint = `/activitypub/1.0/actors/${ testUserId }/following`; } ); test( 'should return 200 status code for following endpoint', async ( { requestUtils } ) => { diff --git a/tests/e2e/specs/includes/rest/inbox-controller.test.js b/tests/e2e/specs/includes/rest/inbox-controller.test.js index 20229640c..bfdad540b 100644 --- a/tests/e2e/specs/includes/rest/inbox-controller.test.js +++ b/tests/e2e/specs/includes/rest/inbox-controller.test.js @@ -10,7 +10,7 @@ test.describe( 'ActivityPub Inbox REST API', () => { test.beforeAll( async ( { requestUtils } ) => { // Use the default test user testUserId = 1; - inboxEndpoint = `/activitypub/1.0/users/${ testUserId }/inbox`; + inboxEndpoint = `/activitypub/1.0/actors/${ testUserId }/inbox`; } ); test( 'should return 200 status code for inbox GET endpoint', async ( { requestUtils } ) => { diff --git a/tests/e2e/specs/includes/rest/outbox-controller.test.js b/tests/e2e/specs/includes/rest/outbox-controller.test.js index 57ca932d7..f57e46b02 100644 --- a/tests/e2e/specs/includes/rest/outbox-controller.test.js +++ b/tests/e2e/specs/includes/rest/outbox-controller.test.js @@ -10,7 +10,7 @@ test.describe( 'ActivityPub Outbox REST API', () => { test.beforeAll( async ( { requestUtils } ) => { // Use the default test user testUserId = 1; - outboxEndpoint = `/activitypub/1.0/users/${ testUserId }/outbox`; + outboxEndpoint = `/activitypub/1.0/actors/${ testUserId }/outbox`; } ); test( 'should return 200 status code for outbox endpoint', async ( { requestUtils } ) => { diff --git a/tests/e2e/specs/includes/rest/replies-controller.test.js b/tests/e2e/specs/includes/rest/replies-controller.test.js index 88ab52738..499bf0ee9 100644 --- a/tests/e2e/specs/includes/rest/replies-controller.test.js +++ b/tests/e2e/specs/includes/rest/replies-controller.test.js @@ -12,7 +12,7 @@ test.describe( 'ActivityPub Replies Collection REST API', () => { // Use the default test user and a sample post testUserId = 1; testPostId = 1; // Assuming a post exists - repliesEndpoint = `/activitypub/1.0/users/${ testUserId }/posts/${ testPostId }/replies`; + repliesEndpoint = `/activitypub/1.0/actors/${ testUserId }/posts/${ testPostId }/replies`; } ); test( 'should return 200 status code for replies endpoint', async ( { requestUtils } ) => { diff --git a/tests/phpunit/tests/includes/class-test-signature.php b/tests/phpunit/tests/includes/class-test-signature.php index dccdc3dce..5e15a8eca 100644 --- a/tests/phpunit/tests/includes/class-test-signature.php +++ b/tests/phpunit/tests/includes/class-test-signature.php @@ -324,6 +324,7 @@ function () use ( $keys ) { 'body' => '{"type":"Create","actor":"https://example.org/author/admin","object":{"type":"Note","content":"Test content."}}', 'key_id' => 'https://example.org/author/admin#main-key', 'private_key' => Actors::get_private_key( 1 ), + 'user_id' => 1, 'headers' => array( 'Content-Type' => 'application/activity+json', 'Date' => \gmdate( 'D, d M Y H:i:s T' ), @@ -402,6 +403,7 @@ function () use ( $keys ) { ), 'key_id' => 'https://example.org/author/admin#main-key', 'private_key' => \openssl_pkey_get_private( $keys['private_key'] ), + 'user_id' => 1, ), 'https://example.org/wp-json/activitypub/1.0/inbox' ); diff --git a/tests/phpunit/tests/includes/scheduler/class-test-collection-sync.php b/tests/phpunit/tests/includes/scheduler/class-test-collection-sync.php new file mode 100644 index 000000000..bbf514474 --- /dev/null +++ b/tests/phpunit/tests/includes/scheduler/class-test-collection-sync.php @@ -0,0 +1,534 @@ +user->create( + array( + 'user_login' => 'test_user', + 'user_email' => 'test@example.com', + ) + ); + } + + /** + * Set up each test. + */ + public function set_up() { + parent::set_up(); + _delete_all_posts(); + } + + /** + * Helper: Create a remote actor post. + * + * @param string $actor_url The actor URL. + * @return int The post ID. + */ + protected function create_remote_actor( $actor_url ) { + $post_id = self::factory()->post->create( + array( + 'post_title' => $actor_url, + 'post_status' => 'publish', + 'post_type' => Remote_Actors::POST_TYPE, + 'guid' => $actor_url, + ) + ); + + // Set the inbox meta. The inbox should contain the home authority + // because get_by_authority filters by inbox containing the authority. + $home_authority = get_url_authority( \home_url() ); + \add_post_meta( $post_id, '_activitypub_inbox', $home_authority . '/inbox' ); + + return $post_id; + } + + /** + * Helper: Add an accepted follow. + * + * @param int $post_id The remote actor post ID. + * @param int $user_id The local user ID. + */ + protected function add_accepted_follow( $post_id, $user_id ) { + \add_post_meta( $post_id, Following::FOLLOWING_META_KEY, $user_id ); + } + + /** + * Helper: Add a pending follow. + * + * @param int $post_id The remote actor post ID. + * @param int $user_id The local user ID. + */ + protected function add_pending_follow( $post_id, $user_id ) { + \add_post_meta( $post_id, Following::PENDING_META_KEY, $user_id ); + } + + /** + * Test schedule_reconciliation schedules the correct action. + * + * @covers ::schedule_reconciliation + */ + public function test_schedule_reconciliation() { + $user_id = self::$user_id; + $actor_url = 'https://example.com/users/test'; + $params = array( + 'url' => 'https://example.com/users/test/followers/sync', + 'collectionId' => 'https://example.com/users/test/followers', + 'digest' => 'abcdef123456', + ); + + // Clear any existing scheduled events. + wp_clear_scheduled_hook( 'activitypub_followers_sync_reconcile', array( $user_id, $actor_url, $params ) ); + + // Schedule the reconciliation. + Collection_Sync::schedule_reconciliation( 'followers', $user_id, $actor_url, $params ); + + // Check that the event was scheduled. + $scheduled = wp_next_scheduled( 'activitypub_followers_sync_reconcile', array( $user_id, $actor_url, $params ) ); + + $this->assertNotFalse( $scheduled, 'Event should be scheduled' ); + $this->assertGreaterThan( time(), $scheduled, 'Event should be scheduled in the future' ); + $this->assertLessThanOrEqual( time() + 61, $scheduled, 'Event should be scheduled within ~60 seconds' ); + + // Clean up. + wp_clear_scheduled_hook( 'activitypub_followers_sync_reconcile', array( $user_id, $actor_url, $params ) ); + } + + /** + * Test reconcile_followers with empty URL parameter. + * + * @covers ::reconcile_followers + */ + public function test_reconcile_followers_empty_url() { + $params = array(); // No URL. + + // Should return early without errors. + Collection_Sync::reconcile_followers( self::$user_id, 'https://example.com/users/test', $params ); + + // No assertions needed - just verify no errors. + $this->assertTrue( true ); + } + + /** + * Test reconcile_followers with invalid remote response. + * + * @covers ::reconcile_followers + */ + public function test_reconcile_followers_invalid_remote_response() { + $params = array( + 'url' => 'https://example.com/invalid', + ); + + // Mock Http::get_remote_object to return an error. + add_filter( + 'activitypub_pre_http_get_remote_object', + function ( $preempt, $url_or_object ) { + if ( 'https://example.com/invalid' === $url_or_object ) { + return new \WP_Error( 'http_request_failed', 'Request failed' ); + } + return $preempt; + }, + 10, + 2 + ); + + // Should return early without errors. + Collection_Sync::reconcile_followers( self::$user_id, 'https://example.com/users/test', $params ); + + // No assertions needed - just verify no errors. + $this->assertTrue( true ); + + // Clean up. + remove_all_filters( 'activitypub_pre_http_get_remote_object' ); + } + + /** + * Test reconcile_followers rejects accepted follows not in remote list. + * + * @covers ::reconcile_followers + */ + public function test_reconcile_followers_rejects_stale_accepted() { + $home_authority = get_url_authority( \home_url() ); + + // Create remote actors. + $alice_id = $this->create_remote_actor( self::$remote_actors[0] ); // example.com/alice. + $bob_id = $this->create_remote_actor( self::$remote_actors[1] ); // example.com/bob. + + // Add both as accepted follows. + $this->add_accepted_follow( $alice_id, self::$user_id ); + $this->add_accepted_follow( $bob_id, self::$user_id ); + + // Verify they're accepted. + $accepted = Following::get_by_authority( self::$user_id, $home_authority ); + $this->assertCount( 2, $accepted ); + + // Mock remote response with only Alice (Bob is missing). + $params = array( + 'url' => 'https://example.com/users/test/followers/sync', + ); + + add_filter( + 'activitypub_pre_http_get_remote_object', + function ( $preempt, $url_or_object ) { + if ( 'https://example.com/users/test/followers/sync' === $url_or_object ) { + return array( + 'type' => 'OrderedCollection', + 'orderedItems' => array( + self::$remote_actors[0], // Only Alice. + ), + ); + } + return $preempt; + }, + 10, + 2 + ); + + // Run reconciliation. + Collection_Sync::reconcile_followers( self::$user_id, 'https://example.com/users/test', $params ); + + // Verify Bob was rejected (no longer accepted). + $accepted = Following::get_by_authority( self::$user_id, $home_authority ); + $this->assertCount( 1, $accepted, 'Bob should have been rejected' ); + $this->assertEquals( self::$remote_actors[0], $accepted[0]->guid, 'Only Alice should remain' ); + + // Clean up. + remove_all_filters( 'activitypub_pre_http_get_remote_object' ); + } + + /** + * Test reconcile_followers accepts pending follows in remote list. + * + * @covers ::reconcile_followers + */ + public function test_reconcile_followers_accepts_pending_in_remote() { + $home_authority = get_url_authority( \home_url() ); + + // Create remote actor Charlie. + $charlie_id = $this->create_remote_actor( self::$remote_actors[2] ); // example.com/charlie. + + // Add as pending follow. + $this->add_pending_follow( $charlie_id, self::$user_id ); + + // Verify it's pending. + $pending = Following::get_by_authority( self::$user_id, $home_authority, Following::PENDING_META_KEY ); + $this->assertCount( 1, $pending ); + + // Mock remote response with Charlie (already accepted on remote). + $params = array( + 'url' => 'https://example.com/users/test/followers/sync', + ); + + add_filter( + 'activitypub_pre_http_get_remote_object', + function ( $preempt, $url_or_object ) { + if ( 'https://example.com/users/test/followers/sync' === $url_or_object ) { + return array( + 'type' => 'OrderedCollection', + 'orderedItems' => array( + self::$remote_actors[2], // Charlie. + ), + ); + } + return $preempt; + }, + 10, + 2 + ); + + // Run reconciliation. + Collection_Sync::reconcile_followers( self::$user_id, 'https://example.com/users/test', $params ); + + // Verify Charlie was accepted. + $accepted = Following::get_by_authority( self::$user_id, $home_authority ); + $this->assertCount( 1, $accepted, 'Charlie should be accepted' ); + $this->assertEquals( self::$remote_actors[2], $accepted[0]->guid ); + + // Verify Charlie is no longer pending. + $pending = Following::get_by_authority( self::$user_id, $home_authority, Following::PENDING_META_KEY ); + $this->assertCount( 0, $pending, 'Charlie should not be pending' ); + + // Clean up. + remove_all_filters( 'activitypub_pre_http_get_remote_object' ); + } + + /** + * Test reconcile_followers rejects pending follows not in remote list. + * + * @covers ::reconcile_followers + */ + public function test_reconcile_followers_rejects_pending_not_in_remote() { + // Create remote actor Dave (mastodon.social). + $dave_id = $this->create_remote_actor( self::$remote_actors[3] ); // mastodon.social/dave. + + // Add as pending follow. + $this->add_pending_follow( $dave_id, self::$user_id ); + + // Mock remote response with empty list (Dave not included). + $params = array( + 'url' => 'https://example.com/users/test/followers/sync', + ); + + add_filter( + 'activitypub_pre_http_get_remote_object', + function ( $preempt, $url_or_object ) { + if ( 'https://example.com/users/test/followers/sync' === $url_or_object ) { + return array( + 'type' => 'OrderedCollection', + 'orderedItems' => array(), // Empty. + ); + } + return $preempt; + }, + 10, + 2 + ); + + // Run reconciliation. + Collection_Sync::reconcile_followers( self::$user_id, 'https://example.com/users/test', $params ); + + // Verify Dave was rejected (not accepted). + $accepted = Following::get_by_authority( self::$user_id, 'https://mastodon.social' ); + $this->assertCount( 0, $accepted, 'Dave should not be accepted' ); + + // Verify Dave is no longer pending. + $pending = Following::get_by_authority( self::$user_id, 'https://mastodon.social', Following::PENDING_META_KEY ); + $this->assertCount( 0, $pending, 'Dave should not be pending' ); + + // Clean up. + remove_all_filters( 'activitypub_pre_http_get_remote_object' ); + } + + /** + * Test reconcile_followers handles mixed scenario correctly. + * + * @covers ::reconcile_followers + */ + public function test_reconcile_followers_mixed_scenario() { + $home_authority = get_url_authority( \home_url() ); + + // Create remote actors from example.com. + $alice_id = $this->create_remote_actor( self::$remote_actors[0] ); // Alice. + $bob_id = $this->create_remote_actor( self::$remote_actors[1] ); // Bob. + $charlie_id = $this->create_remote_actor( self::$remote_actors[2] ); // Charlie. + + // Alice: accepted, in remote (should stay accepted). + $this->add_accepted_follow( $alice_id, self::$user_id ); + + // Bob: accepted, NOT in remote (should be rejected). + $this->add_accepted_follow( $bob_id, self::$user_id ); + + // Charlie: pending, in remote (should be accepted). + $this->add_pending_follow( $charlie_id, self::$user_id ); + + // Mock remote response with Alice and Charlie only. + $params = array( + 'url' => 'https://example.com/users/test/followers/sync', + ); + + add_filter( + 'activitypub_pre_http_get_remote_object', + function ( $preempt, $url_or_object ) { + if ( 'https://example.com/users/test/followers/sync' === $url_or_object ) { + return array( + 'type' => 'OrderedCollection', + 'orderedItems' => array( + self::$remote_actors[0], // Alice. + self::$remote_actors[2], // Charlie. + ), + ); + } + return $preempt; + }, + 10, + 2 + ); + + // Run reconciliation. + Collection_Sync::reconcile_followers( self::$user_id, 'https://example.com/users/test', $params ); + + // Verify final state. + $accepted = Following::get_by_authority( self::$user_id, $home_authority ); + $this->assertCount( 2, $accepted, 'Should have 2 accepted follows' ); + + $accepted_guids = array_map( + function ( $post ) { + return $post->guid; + }, + $accepted + ); + sort( $accepted_guids ); + + $expected = array( self::$remote_actors[0], self::$remote_actors[2] ); + sort( $expected ); + + $this->assertEquals( $expected, $accepted_guids, 'Alice and Charlie should be accepted' ); + + // Verify no pending follows remain. + $pending = Following::get_by_authority( self::$user_id, $home_authority, Following::PENDING_META_KEY ); + $this->assertCount( 0, $pending, 'No pending follows should remain' ); + + // Clean up. + remove_all_filters( 'activitypub_pre_http_get_remote_object' ); + } + + /** + * Test reconcile_followers fires action hook on completion. + * + * @covers ::reconcile_followers + */ + public function test_reconcile_followers_fires_action_hook() { + $action_fired = false; + $hook_user_id = null; + $hook_actor = null; + + add_action( + 'activitypub_followers_sync_reconciled', + function ( $user_id, $actor_url ) use ( &$action_fired, &$hook_user_id, &$hook_actor ) { + $action_fired = true; + $hook_user_id = $user_id; + $hook_actor = $actor_url; + }, + 10, + 2 + ); + + $params = array( + 'url' => 'https://example.com/users/test/followers/sync', + ); + + add_filter( + 'activitypub_pre_http_get_remote_object', + function ( $preempt, $url_or_object ) { + if ( 'https://example.com/users/test/followers/sync' === $url_or_object ) { + return array( + 'type' => 'OrderedCollection', + 'orderedItems' => array(), + ); + } + return $preempt; + }, + 10, + 2 + ); + + // Run reconciliation. + Collection_Sync::reconcile_followers( self::$user_id, 'https://example.com/users/test', $params ); + + // Verify action was fired with correct parameters. + $this->assertTrue( $action_fired, 'Action hook should fire' ); + $this->assertEquals( self::$user_id, $hook_user_id ); + $this->assertEquals( 'https://example.com/users/test', $hook_actor ); + + // Clean up. + remove_all_filters( 'activitypub_pre_http_get_remote_object' ); + remove_all_actions( 'activitypub_followers_sync_reconciled' ); + } + + /** + * Test reconcile_followers only processes followers from home authority. + * + * @covers ::reconcile_followers + */ + public function test_reconcile_followers_filters_by_authority() { + $home_authority = get_url_authority( \home_url() ); + + // Create actors with different inbox authorities. + // Alice has home authority inbox (will be processed). + $alice_id = $this->create_remote_actor( self::$remote_actors[0] ); // Has home authority inbox. + + // Dave has different authority inbox (won't be processed). + $dave_id = self::factory()->post->create( + array( + 'post_title' => self::$remote_actors[3], + 'post_status' => 'publish', + 'post_type' => Remote_Actors::POST_TYPE, + 'guid' => self::$remote_actors[3], + ) + ); + \add_post_meta( $dave_id, '_activitypub_inbox', 'https://mastodon.social/inbox' ); + + // Add both as accepted follows. + $this->add_accepted_follow( $alice_id, self::$user_id ); + $this->add_accepted_follow( $dave_id, self::$user_id ); + + // Mock remote response with empty list (should only affect home authority followers). + $params = array( + 'url' => 'https://example.com/users/test/followers/sync', + ); + + add_filter( + 'activitypub_pre_http_get_remote_object', + function ( $preempt, $url_or_object ) { + if ( 'https://example.com/users/test/followers/sync' === $url_or_object ) { + return array( + 'type' => 'OrderedCollection', + 'orderedItems' => array(), + ); + } + return $preempt; + }, + 10, + 2 + ); + + // Run reconciliation. + Collection_Sync::reconcile_followers( self::$user_id, 'https://example.com/users/test', $params ); + + // Verify only home authority followers were processed. + // Alice should be rejected (from home authority, not in remote list). + $home_accepted = Following::get_by_authority( self::$user_id, $home_authority ); + $this->assertCount( 0, $home_accepted, 'Home authority followers should be processed' ); + + // Dave should remain (different authority, not processed). + $mastodon_accepted = Following::get_by_authority( self::$user_id, 'https://mastodon.social' ); + $this->assertCount( 1, $mastodon_accepted, 'Other authority followers should not be affected' ); + + // Clean up. + remove_all_filters( 'activitypub_pre_http_get_remote_object' ); + } +}