diff --git a/.github/changelog/add-same-server-domain-move b/.github/changelog/add-same-server-domain-move new file mode 100644 index 000000000..1b4b9fc40 --- /dev/null +++ b/.github/changelog/add-same-server-domain-move @@ -0,0 +1,4 @@ +Significance: major +Type: added + +Support same-server domain migrations ⏩ diff --git a/activitypub.php b/activitypub.php index 0a89fded8..5bec4e6d7 100644 --- a/activitypub.php +++ b/activitypub.php @@ -76,6 +76,7 @@ function plugin_init() { \add_action( 'init', array( __NAMESPACE__ . '\Mailer', 'init' ) ); \add_action( 'init', array( __NAMESPACE__ . '\Mention', 'init' ) ); \add_action( 'init', array( __NAMESPACE__ . '\Migration', 'init' ), 1 ); + \add_action( 'init', array( __NAMESPACE__ . '\Move', 'init' ) ); \add_action( 'init', array( __NAMESPACE__ . '\Options', 'init' ) ); \add_action( 'init', array( __NAMESPACE__ . '\Scheduler', 'init' ) ); diff --git a/includes/class-activitypub.php b/includes/class-activitypub.php index 902050f01..1bf61988f 100644 --- a/includes/class-activitypub.php +++ b/includes/class-activitypub.php @@ -762,6 +762,15 @@ public static function register_user_meta() { ) ); + \register_meta( + 'user', + $blog_prefix . 'activitypub_old_host_data', + array( + 'description' => 'Actor object for the user on the old host.', + 'single' => true, + ) + ); + \register_meta( 'user', $blog_prefix . 'activitypub_moved_to', diff --git a/includes/class-dispatcher.php b/includes/class-dispatcher.php index 09198789f..b76f00d1c 100644 --- a/includes/class-dispatcher.php +++ b/includes/class-dispatcher.php @@ -209,6 +209,15 @@ private static function send_to_inboxes( $inboxes, $outbox_item_id ) { $actor = Outbox::get_actor( \get_post( $outbox_item_id ) ); $retries = array(); + /** + * Fires before sending an Activity to inboxes. + * + * @param string $json The ActivityPub Activity JSON. + * @param array $inboxes The inboxes to send to. + * @param int $outbox_item_id The Outbox item ID. + */ + \do_action( 'activitypub_pre_send_to_inboxes', $json, $inboxes, $outbox_item_id ); + foreach ( $inboxes as $inbox ) { $result = safe_remote_post( $inbox, $json, $actor->get__id() ); diff --git a/includes/class-move.php b/includes/class-move.php index 5e0c1acb0..18e54ad4d 100644 --- a/includes/class-move.php +++ b/includes/class-move.php @@ -10,6 +10,8 @@ use Activitypub\Activity\Actor; use Activitypub\Activity\Activity; use Activitypub\Collection\Actors; +use Activitypub\Model\Blog; +use Activitypub\Model\User; /** * ActivityPub (Account) Move Class @@ -17,6 +19,33 @@ * @author Matthias Pfefferle */ class Move { + + /** + * Initialize the Move class. + */ + public static function init() { + /** + * Filter to enable automatically moving Fediverse accounts when the domain changes. + * + * @param bool $domain_moves_enabled Whether domain moves are enabled. + */ + $domain_moves_enabled = apply_filters( 'activitypub_enable_primary_domain_moves', false ); + + if ( $domain_moves_enabled ) { + // Add the filter to change the domain. + \add_filter( 'update_option_home', array( self::class, 'change_domain' ), 10, 2 ); + + if ( get_option( 'activitypub_old_host' ) ) { + \add_action( 'activitypub_construct_model_actor', array( self::class, 'maybe_initiate_old_user' ) ); + \add_action( 'activitypub_pre_send_to_inboxes', array( self::class, 'pre_send_to_inboxes' ) ); + + if ( ! is_user_type_disabled( 'blog' ) ) { + \add_filter( 'activitypub_pre_get_by_username', array( self::class, 'old_blog_username' ), 10, 2 ); + } + } + } + } + /** * Move an ActivityPub account from one location to another. * @@ -161,4 +190,124 @@ private static function update_blog_also_known_as( $from ) { \update_option( 'activitypub_blog_user_also_known_as', $also_known_as ); } + + /** + * Change domain for all ActivityPub Actors. + * + * This method handles domain migration according to the ActivityPub Data Portability spec. + * It stores the old host and calls Move::internally for each available profile. + * It also caches the JSON representation of the old Actor for future lookups. + * + * @param string $from The old domain. + * @param string $to The new domain. + * + * @return array Array of results from Move::internally calls. + */ + public static function change_domain( $from, $to ) { + // Get all actors that need to be migrated. + $actors = Actors::get_all(); + + $results = array(); + $to_host = \wp_parse_url( $to, \PHP_URL_HOST ); + $from_host = \wp_parse_url( $from, \PHP_URL_HOST ); + + // Store the old host for future reference. + \update_option( 'activitypub_old_host', $from_host ); + + // Process each actor. + foreach ( $actors as $actor ) { + $actor_id = $actor->get_id(); + + // Replace the new host with the old host in the actor ID. + $old_actor_id = str_replace( $to_host, $from_host, $actor_id ); + + // Call Move::internally for this actor. + $result = self::internally( $old_actor_id, $actor_id ); + + if ( \is_wp_error( $result ) ) { + // Log the error and continue with the next actor. + Debug::write_log( 'Error moving actor: ' . $actor_id . ' - ' . $result->get_error_message() ); + continue; + } + + $json = str_replace( $to_host, $from_host, $actor->to_json() ); + + // Save the current actor data after migration. + if ( $actor instanceof Blog ) { + \update_option( 'activitypub_blog_user_old_host_data', $json, false ); + } else { + \update_user_option( $actor->get__id(), 'activitypub_old_host_data', $json, false ); + } + + $results[] = array( + 'actor' => $actor_id, + 'result' => $result, + ); + } + + return $results; + } + + /** + * Maybe initiate old user. + * + * This method checks if the current request domain matches the old host. + * If it does, it retrieves the cached data for the user and populates the instance. + * + * @param Blog|User $instance The Blog or User instance to populate. + */ + public static function maybe_initiate_old_user( $instance ) { + if ( ! Query::get_instance()->is_old_host_request() ) { + return; + } + + if ( $instance instanceof Blog ) { + $cached_data = \get_option( 'activitypub_blog_user_old_host_data' ); + } elseif ( $instance instanceof User ) { + $cached_data = \get_user_option( 'activitypub_old_host_data', $instance->get__id() ); + } + + if ( ! empty( $cached_data ) ) { + $instance->from_json( $cached_data ); + } + } + + /** + * Pre-send to inboxes. + * + * @param string $json The ActivityPub Activity JSON. + */ + public static function pre_send_to_inboxes( $json ) { + $json = json_decode( $json, true ); + + if ( 'Move' !== $json['type'] ) { + return; + } + + if ( is_same_domain( $json['object'] ) ) { + return; + } + + Query::get_instance()->set_old_host_request(); + } + + /** + * Filter to return the old blog username. + * + * @param null $pre The pre-existing value. + * @param string $username The username to check. + * + * @return Blog|null The old blog instance or null. + */ + public static function old_blog_username( $pre, $username ) { + $old_host = \get_option( 'activitypub_old_host' ); + + // Special case for Blog Actor on old host. + if ( $old_host === $username && Query::get_instance()->is_old_host_request() ) { + // Return a new Blog instance which will load the cached data in its constructor. + $pre = new Blog(); + } + + return $pre; + } } diff --git a/includes/class-query.php b/includes/class-query.php index d8bcc9fb2..d2200c0f4 100644 --- a/includes/class-query.php +++ b/includes/class-query.php @@ -48,6 +48,13 @@ class Query { */ private $is_activitypub_request; + /** + * Whether the current request is from the old host. + * + * @var bool + */ + private $is_old_host_request; + /** * The constructor. */ @@ -305,4 +312,40 @@ public function is_activitypub_request() { return false; } + + /** + * Check if the current request is from the old host. + * + * @return bool True if the request is from the old host, false otherwise. + */ + public function is_old_host_request() { + if ( isset( $this->is_old_host_request ) ) { + return $this->is_old_host_request; + } + + $old_host = \get_option( 'activitypub_old_host' ); + + if ( ! $old_host ) { + $this->is_old_host_request = false; + return false; + } + + $request_host = isset( $_SERVER['HTTP_HOST'] ) ? \sanitize_text_field( \wp_unslash( $_SERVER['HTTP_HOST'] ) ) : ''; + $referer_host = isset( $_SERVER['HTTP_REFERER'] ) ? \wp_parse_url( \sanitize_text_field( \wp_unslash( $_SERVER['HTTP_REFERER'] ) ), PHP_URL_HOST ) : ''; + + // Check if the domain matches either the request domain or referer. + $check = $old_host === $request_host || $old_host === $referer_host; + $this->is_old_host_request = $check; + + return $check; + } + + /** + * Fake an old host request. + * + * @param bool $state Optional. The state to set. Default true. + */ + public function set_old_host_request( $state = true ) { + $this->is_old_host_request = $state; + } } diff --git a/includes/class-signature.php b/includes/class-signature.php index 89aa52114..98cfec229 100644 --- a/includes/class-signature.php +++ b/includes/class-signature.php @@ -267,17 +267,11 @@ public static function verify_http_signature( $request ) { $headers['(request-target)'][0] = strtolower( $headers['request_method'][0] ) . ' ' . $headers['request_uri'][0]; } - if ( ! isset( $headers['signature'] ) ) { - return new WP_Error( 'activitypub_signature', __( 'Request not signed', 'activitypub' ), array( 'status' => 401 ) ); - } - if ( array_key_exists( 'signature', $headers ) ) { $signature_block = self::parse_signature_header( $headers['signature'][0] ); } elseif ( array_key_exists( 'authorization', $headers ) ) { $signature_block = self::parse_signature_header( $headers['authorization'][0] ); - } - - if ( ! isset( $signature_block ) || ! $signature_block ) { + } else { return new WP_Error( 'activitypub_signature', __( 'Incompatible request signature. keyId and signature are required', 'activitypub' ), array( 'status' => 401 ) ); } diff --git a/includes/collection/class-actors.php b/includes/collection/class-actors.php index df2e99918..d4dbd6ec5 100644 --- a/includes/collection/class-actors.php +++ b/includes/collection/class-actors.php @@ -76,6 +76,17 @@ public static function get_by_id( $user_id ) { * @return User|Blog|Application|WP_Error The Actor or WP_Error if user not found. */ public static function get_by_username( $username ) { + /** + * Filter the username before we do anything else. + * + * @param null $pre The pre-existing value. + * @param string $username The username. + */ + $pre = apply_filters( 'activitypub_pre_get_by_username', null, $username ); + if ( null !== $pre ) { + return $pre; + } + // Check for blog user. if ( Blog::get_default_username() === $username ) { return new Blog(); @@ -110,7 +121,10 @@ public static function get_by_username( $username ) { ); if ( $user->results ) { - return self::get_by_id( $user->results[0] ); + $actor = self::get_by_id( $user->results[0] ); + if ( ! \is_wp_error( $actor ) ) { + return $actor; + } } $username = str_replace( array( '*', '%' ), '', $username ); @@ -128,7 +142,10 @@ public static function get_by_username( $username ) { ); if ( $user->results ) { - return self::get_by_id( $user->results[0] ); + $actor = self::get_by_id( $user->results[0] ); + if ( ! \is_wp_error( $actor ) ) { + return $actor; + } } return new WP_Error( @@ -164,6 +181,9 @@ public static function get_by_resource( $uri ) { $scheme = \esc_attr( $match[1] ); } + // @todo: handle old domain URIs here before we serve a new domain below when we shouldn't. + // Although maybe passing through to ::get_by_username() is enough? + switch ( $scheme ) { // Check for http(s) URIs. case 'http': @@ -217,7 +237,7 @@ public static function get_by_resource( $uri ) { $host = normalize_host( \substr( \strrchr( $uri, '@' ), 1 ) ); $blog_host = normalize_host( \wp_parse_url( \home_url( '/' ), \PHP_URL_HOST ) ); - if ( $blog_host !== $host ) { + if ( $blog_host !== $host && get_option( 'activitypub_old_host' ) !== $host ) { return new WP_Error( 'activitypub_wrong_host', \__( 'Resource host does not match blog host', 'activitypub' ), @@ -299,6 +319,43 @@ public static function get_collection() { return $return; } + /** + * Get all active Actors including the Blog Actor. + * + * @return array The actor collection. + */ + public static function get_all() { + $return = array(); + + if ( ! is_user_type_disabled( 'user' ) ) { + $users = \get_users( + array( + 'capability__in' => array( 'activitypub' ), + ) + ); + + foreach ( $users as $user ) { + $actor = User::from_wp_user( $user->ID ); + + if ( \is_wp_error( $actor ) ) { + continue; + } + + $return[] = $actor; + } + } + + // Also include the blog actor if active. + if ( ! is_user_type_disabled( 'blog' ) ) { + $blog_actor = self::get_by_id( self::BLOG_USER_ID ); + if ( ! \is_wp_error( $blog_actor ) ) { + $return[] = $blog_actor; + } + } + + return $return; + } + /** * Returns the actor type based on the user ID. * diff --git a/includes/functions.php b/includes/functions.php index 09509bea6..d71f29995 100644 --- a/includes/functions.php +++ b/includes/functions.php @@ -134,7 +134,8 @@ function url_to_authorid( $url ) { global $wp_rewrite; // Check if url hase the same host. - if ( \wp_parse_url( \home_url(), \PHP_URL_HOST ) !== \wp_parse_url( $url, \PHP_URL_HOST ) ) { + $request_host = \wp_parse_url( $url, \PHP_URL_HOST ); + if ( \wp_parse_url( \home_url(), \PHP_URL_HOST ) !== $request_host && get_option( 'activitypub_old_host' ) !== $request_host ) { return null; } diff --git a/includes/model/class-blog.php b/includes/model/class-blog.php index 23c95bcb9..e46e5b025 100644 --- a/includes/model/class-blog.php +++ b/includes/model/class-blog.php @@ -7,12 +7,11 @@ namespace Activitypub\Model; -use WP_Query; - -use Activitypub\Signature; use Activitypub\Activity\Actor; use Activitypub\Collection\Actors; use Activitypub\Collection\Extra_Fields; +use Activitypub\Signature; +use WP_Query; use function Activitypub\esc_hashtag; use function Activitypub\is_single_user; @@ -92,6 +91,18 @@ class Blog extends Actor { */ protected $posting_restricted_to_mods; + /** + * Constructor. + */ + public function __construct() { + /** + * Fires when a model actor is constructed. + * + * @param Blog $this The Blog model. + */ + \do_action( 'activitypub_construct_model_actor', $this ); + } + /** * Whether the User manually approves followers. * @@ -116,6 +127,12 @@ public function get_discoverable() { * @return string The User ID. */ public function get_id() { + $id = parent::get_id(); + + if ( $id ) { + return $id; + } + $permalink = \get_option( 'activitypub_use_permalink_as_id_for_blog', false ); if ( $permalink ) { @@ -576,7 +593,8 @@ public function get_also_known_as() { * @return string The movedTo. */ public function get_moved_to() { - // phpcs:ignore Universal.Operators.DisallowShortTernary.Found - return \get_option( 'activitypub_blog_user_moved_to' ) ?: null; + $moved_to = \get_option( 'activitypub_blog_user_moved_to' ); + + return $moved_to && $moved_to !== $this->get_id() ? $moved_to : null; } } diff --git a/includes/model/class-user.php b/includes/model/class-user.php index c12fccafc..964c78677 100644 --- a/includes/model/class-user.php +++ b/includes/model/class-user.php @@ -7,10 +7,10 @@ namespace Activitypub\Model; -use WP_Error; -use Activitypub\Signature; use Activitypub\Activity\Actor; use Activitypub\Collection\Extra_Fields; +use Activitypub\Http; +use Activitypub\Signature; use function Activitypub\is_blog_public; use function Activitypub\get_rest_url_by_path; @@ -71,6 +71,24 @@ class User extends Actor { */ protected $webfinger; + /** + * Constructor. + * + * @param int $user_id Optional. The WordPress user ID. Default null. + */ + public function __construct( $user_id = null ) { + if ( $user_id ) { + $this->_id = $user_id; + + /** + * Fires when a model actor is constructed. + * + * @param User $this The User object. + */ + \do_action( 'activitypub_construct_model_actor', $this ); + } + } + /** * The type of the object. * @@ -85,21 +103,18 @@ public function get_type() { * * @param int $user_id The user ID. * - * @return WP_Error|User The User object or WP_Error if user not found. + * @return \WP_Error|User The User object or \WP_Error if user not found. */ public static function from_wp_user( $user_id ) { if ( ! user_can_activitypub( $user_id ) ) { - return new WP_Error( + return new \WP_Error( 'activitypub_user_not_found', \__( 'User not found', 'activitypub' ), array( 'status' => 404 ) ); } - $object = new static(); - $object->_id = $user_id; - - return $object; + return new static( $user_id ); } /** @@ -108,6 +123,12 @@ public static function from_wp_user( $user_id ) { * @return string The user ID. */ public function get_id() { + $id = parent::get_id(); + + if ( $id ) { + return $id; + } + $permalink = \get_user_option( 'activitypub_use_permalink_as_id', $this->_id ); if ( '1' === $permalink ) { @@ -367,7 +388,7 @@ public function get_indexable() { * Update the username. * * @param string $value The new value. - * @return int|WP_Error The updated user ID or WP_Error on failure. + * @return int|\WP_Error The updated user ID or \WP_Error on failure. */ public function update_name( $value ) { $userdata = array( @@ -446,7 +467,8 @@ public function get_also_known_as() { * @return string The movedTo. */ public function get_moved_to() { - // phpcs:ignore Universal.Operators.DisallowShortTernary.Found - return \get_user_option( 'activitypub_moved_to', $this->_id ) ?: null; + $moved_to = \get_user_option( 'activitypub_moved_to', $this->_id ); + + return $moved_to && $moved_to !== $this->get_id() ? $moved_to : null; } } diff --git a/tests/includes/class-test-move.php b/tests/includes/class-test-move.php index 57ff882ee..a56ee23e0 100644 --- a/tests/includes/class-test-move.php +++ b/tests/includes/class-test-move.php @@ -8,12 +8,13 @@ namespace Activitypub\Tests; use Activitypub\Collection\Actors; -use Activitypub\Model\User; +use Activitypub\Model\Blog; +use Activitypub\Move; /** * Test class for Activitypub Move. * - * @coversDefaultClass \Activitypub\Move + * @coversDefaultClass Move */ class Test_Move extends \WP_UnitTestCase { @@ -40,7 +41,7 @@ public function test_account_with_valid_input() { $from = Actors::get_by_id( self::$user_id )->get_id(); $to = 'https://newsite.com/user/1'; - \Activitypub\Move::externally( $from, $to ); + Move::externally( $from, $to ); $moved_to = Actors::get_by_id( self::$user_id )->get_moved_to(); $this->assertEquals( $to, $moved_to ); @@ -55,7 +56,7 @@ public function test_account_with_valid_input() { * @covers ::account */ public function test_account_with_invalid_user() { - $result = \Activitypub\Move::externally( + $result = Move::externally( 'https://example.com/nonexistent/user', 'https://newsite.com/user/999' ); @@ -78,7 +79,7 @@ public function test_account_with_invalid_target() { }; \add_filter( 'pre_http_request', $filter ); - $result = \Activitypub\Move::externally( $from, $to ); + $result = Move::externally( $from, $to ); $this->assertWPError( $result ); $this->assertEquals( 'http_request_failed', $result->get_error_code() ); @@ -105,7 +106,7 @@ public function test_account_with_duplicate_moves() { }; \add_filter( 'pre_http_request', $filter ); - \Activitypub\Move::externally( $from, $to ); + Move::externally( $from, $to ); $moved_to = Actors::get_by_id( self::$user_id )->get_moved_to(); $this->assertEquals( $to, $moved_to ); @@ -125,7 +126,7 @@ public function test_account_with_blog_author_as_actor() { $from = Actors::get_by_id( Actors::BLOG_USER_ID )->get_id(); $to = 'https://newsite.com/user/0'; - \Activitypub\Move::externally( $from, $to ); + Move::externally( $from, $to ); $moved_to = Actors::get_by_id( Actors::BLOG_USER_ID )->get_moved_to(); $this->assertEquals( $to, $moved_to ); @@ -142,13 +143,14 @@ public function test_internally_with_valid_input() { $from = get_author_posts_url( self::$user_id ); $to = Actors::get_by_id( self::$user_id )->get_id(); - \Activitypub\Move::internally( $from, $to ); + Move::internally( $from, $to ); // Clear cache. wp_cache_delete( self::$user_id, 'users' ); + // Updated user should not have moved_to set. $moved_to = Actors::get_by_id( self::$user_id )->get_moved_to(); - $this->assertEquals( $to, $moved_to ); + $this->assertNull( $moved_to ); $also_known_as = Actors::get_by_id( self::$user_id )->get_also_known_as(); $this->assertContains( $from, $also_known_as ); @@ -164,7 +166,7 @@ public function test_internally_activity_object_properties() { $to = Actors::get_by_id( self::$user_id )->get_id(); // Call the method and get the outbox item ID. - $outbox_id = \Activitypub\Move::internally( $from, $to ); + $outbox_id = Move::internally( $from, $to ); // Verify we got a valid outbox ID. $this->assertIsInt( $outbox_id ); @@ -187,4 +189,43 @@ public function test_internally_activity_object_properties() { $this->assertEquals( $from, $activity->origin ); $this->assertEquals( $to, $activity->target ); } + + /** + * Test the change_domain() method with valid input. + * + * @covers ::change_domain + */ + public function test_change_domain_with_valid_input() { + // Enable blog actor. + \update_option( 'activitypub_actor_mode', ACTIVITYPUB_ACTOR_AND_BLOG_MODE ); + + $old_domain = home_url(); + $new_domain = 'http://newdomain.com'; + \remove_filter( 'option_home', '_config_wp_home' ); + \update_option( 'home', $new_domain ); + + // Run the domain change. + $results = Move::change_domain( $old_domain, $new_domain ); + + // Verify the results. + $this->assertIsArray( $results ); + + // Check that each result has the expected structure. + $result = reset( $results ); + $outbox_item = json_decode( get_post_field( 'post_content', $result['result'] ) ); + + $this->assertSame( $outbox_item->target, $result['actor'] ); + $this->assertStringStartsWith( $new_domain, $outbox_item->target ); + + // Verify the old host was stored. + $this->assertEquals( 'example.org', \get_option( 'activitypub_old_host' ) ); + + // Clean up. + \delete_option( 'activitypub_old_host' ); + \delete_option( 'activitypub_blog_user_old_host_data' ); + \delete_option( 'activitypub_actor_mode' ); + \update_option( 'home', $old_domain ); + \add_filter( 'option_home', '_config_wp_home' ); + \delete_user_option( self::$user_id, 'activitypub_old_host_data' ); + } } diff --git a/tests/includes/model/class-test-blog.php b/tests/includes/model/class-test-blog.php new file mode 100644 index 000000000..eb154afa8 --- /dev/null +++ b/tests/includes/model/class-test-blog.php @@ -0,0 +1,72 @@ +assertSame( 'http://newdomain.com/?author=0', ( new Blog() )->get_id() ); + + // Set up the old host. + $_SERVER['HTTP_HOST'] = \wp_parse_url( $old_domain, PHP_URL_HOST ); + + // Blog now returns old blog actor. + \add_action( 'activitypub_construct_model_actor', array( Move::class, 'maybe_initiate_old_user' ) ); + $blog = ( new Blog() )->to_array(); + $this->assertSame( add_query_arg( 'author', 0, $old_domain ), $blog['id'] ); + \remove_action( 'activitypub_construct_model_actor', array( Move::class, 'maybe_initiate_old_user' ) ); + + // Clean up. + \delete_option( 'activitypub_old_host' ); + \delete_option( 'activitypub_blog_user_old_host_data' ); + \update_option( 'home', $old_domain ); + \add_filter( 'option_home', '_config_wp_home' ); + } +} diff --git a/tests/includes/model/class-test-user.php b/tests/includes/model/class-test-user.php index fd56d1a07..a0bd6e728 100644 --- a/tests/includes/model/class-test-user.php +++ b/tests/includes/model/class-test-user.php @@ -8,14 +8,48 @@ namespace Activitypub\Tests\Model; use Activitypub\Model\User; +use Activitypub\Move; /** * Test class for Activitypub User. * - * @coversDefaultClass \Activitypub\Model\User + * @coversDefaultClass User */ class Test_User extends \WP_UnitTestCase { + /** + * Test the Blog constructor. + * + * @covers ::__construct + */ + public function test___construct() { + $old_domain = home_url( '/' ); + $new_domain = 'http://newdomain.com'; + \remove_filter( 'option_home', '_config_wp_home' ); + + \add_filter( 'update_option_home', array( Move::class, 'change_domain' ), 10, 2 ); + \update_option( 'home', $new_domain ); + \remove_filter( 'update_option_home', array( Move::class, 'change_domain' ) ); + + // New domain is set. + $this->assertSame( 'http://newdomain.com/?author=1', ( new User( 1 ) )->get_id() ); + + // Set up the old host. + $_SERVER['HTTP_HOST'] = \wp_parse_url( $old_domain, PHP_URL_HOST ); + + // User now returns old user actor. + \add_action( 'activitypub_construct_model_actor', array( Move::class, 'maybe_initiate_old_user' ) ); + $user = ( new User( 1 ) )->to_array(); + $this->assertSame( add_query_arg( 'author', 1, $old_domain ), $user['id'] ); + \remove_action( 'activitypub_construct_model_actor', array( Move::class, 'maybe_initiate_old_user' ) ); + + // Clean up. + \delete_option( 'activitypub_old_host' ); + \delete_option( 'activitypub_blog_user_old_host_data' ); + \update_option( 'home', $old_domain ); + \add_filter( 'option_home', '_config_wp_home' ); + } + /** * Test the activitypub capability. */