diff --git a/README.md b/README.md index 91e59b2f..0d70362c 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Source for the [Migrate to WordPress.com](https://wordpress.org/plugins/wpcom-mi - `plugin/connection/` — `Connection` (package setup, connect, disconnect), `Connect_Page` (the connection section of the screen), `bootstrap.php`. - `bin/build.sh` — writes `build/wpcom-migration/` and `build/wpcom-migration.zip`. - `bin/check-autoload-manifest.php` — asserts which classes the ZIP publishes through the Jetpack autoloader. -- `tests/e2e/` — Playground blueprints and request scripts: one scenario per credential state, one that drives the Reprint migration screen, two that provision through the REST routes (with a public key, and with a shared secret on a host made to look like it lacks OpenSSL), one that drives the WordPress.com connection, one that checks the admin menu, and one that checks the menu on a network. +- `tests/e2e/` — Playground blueprints and request scripts: one scenario per credential state, two that drive the screens (with a public key, and with a shared secret), two that provision through the REST routes (with a public key, and with a shared secret on a host made to look like it lacks OpenSSL), one that drives the WordPress.com connection, one that checks the admin menu, and one that checks the menu on a network. ## Build @@ -24,44 +24,41 @@ The build runs `composer install` in `plugin/`, copies the tree to a staging dir To activate a source checkout directly (without a build), run `composer install --no-dev --working-dir=plugin` first; without `plugin/vendor/` the plugin activates but the exporter and the WordPress.com connection are absent. -## The Reprint migration screen +## The screens -`wp-admin/admin.php?page=wpcom-migration-status` (the plugin's one menu entry; `manage_options` to view; the `administrator` role to connect, disconnect, or provision through the routes; single-site only). The screen shows one mode at a time: +`wp-admin/admin.php?page=wpcom-migration-status` is the Reprint migration screen. It tells the administrator where the site stands in the migration and offers the next step: connect the site to WordPress.com or continue on WordPress.com. Viewing it needs `manage_options`. Connecting, disconnecting, and provisioning through the routes need the `administrator` role. It works on single sites only, and shows one mode at a time: | Mode | When | Shows | |---|---|---| | The exporter runs on single sites only | Multisite | Nothing | | The export credential no longer matches this site | The site's salts changed | *Connect WordPress.com*, or *Go to WordPress.com* and *Disconnect* when connected | -| Pill: *Site ready to migrate* | The exporter is on | *Go to WordPress.com* | -| Pill: *Connected to WordPress.com* | Logged in; the exporter is off | *Go to WordPress.com*, a *Disconnect* link | -| Pill: *Set up by WordPress.com* | Provisioned with an application password; the exporter is off | *Go to WordPress.com* | +| Pill: *Site ready to migrate* | A credential is stored, so the exporter is on | *Go to WordPress.com* | +| Pill: *Connected to WordPress.com* | Logged in; no credential yet | *Go to WordPress.com*, a *Disconnect* link | | Log in with your WordPress.com account to connect this site | Fresh site | *Connect WordPress.com* | `Settings_Page::mode()` picks the first that fits, top to bottom. *Go to WordPress.com* links to `https://my.wordpress.com/sites`; the `wpcom_migration_continue_url` filter changes that. -Every activation sends the administrator to the main screen on their next wp-admin page load, except a bulk activation, which stays on the plugin list. +`wp-admin/admin.php?page=wpcom-migration-manual` sets up Reprint by hand; see [Setting up Reprint manually](#setting-up-reprint-manually). -The old BlogVault screen at `wp-admin/admin.php?page=wpcom-migration` is no longer in the sidebar. It stays reachable at its URL. +`wp-admin/admin.php?page=wpcom-migration` is the BlogVault screen the plugin had before Reprint. -Until the WordPress.com side of Reprint ships, WordPress.com decides which of the two screens is main: `Main_Screen` asks `GET https://public-api.wordpress.com/wpcom/v2/migration-plugin-config` for `{ "main_screen": "reprint" | "blogvault", "ttl": }` on the first wp-admin page load after its stored answer (`wpcom_migration_main_screen`) expires. With `blogvault`, the old screen gets the sidebar row, the *Settings* link and the activation redirect, and the Reprint screen is reachable only at its URL. A failed fetch keeps the stored answer, or uses `blogvault` when there is none, and retries after five minutes. Networks never ask. +Until the WordPress.com side of Reprint ships, WordPress.com decides which of the Reprint and BlogVault screens is the main one: `Main_Screen` asks `GET https://public-api.wordpress.com/wpcom/v2/migration-plugin-config` for `{ "main_screen": "reprint" | "blogvault", "ttl": }` on the first wp-admin page load after its stored answer (`wpcom_migration_main_screen`) expires. A failed fetch keeps the stored answer, or uses `blogvault` when there is none, and retries after five minutes. The main screen gets the plugin's one sidebar row, the *Settings* link on the plugin list, and the activation redirect: every activation sends the administrator to it on their next wp-admin page load, except a bulk activation, which stays on the plugin list. The other screen stays reachable at its URL, as does the manual one. A brand whitelabelled with `hide` or `hide_from_menu` gets no sidebar row for either screen. -On a network the old screen keeps its row in the network admin, and the *Settings* link and the activation redirect lead there; subsites get no row. A brand whitelabelled with `hide` or `hide_from_menu` takes away the Reprint screen's row too; the screen stays reachable at its URL. +Networks never ask. On a network the BlogVault screen keeps its row in the network admin, and the *Settings* link and the activation redirect lead there; subsites get no row. ## Setting up Reprint manually -`wp-admin/admin.php?page=wpcom-migration-manual` (`administrator` role, single-site only). Nothing links to it; support gives out the URL. It holds the export secret form and a *Remove secret* button; on a host with `openssl_verify()`, a public key textarea (*Enroll key*, PEM or one line, replacing any enrolled key) and a table with the enrolled key's id and a *Remove* button; the exporter toggle, the export URL and the WordPress.com blog ID. Removing either credential turns the exporter off and leaves the other in place. Deactivating the plugin disconnects and discards the secret and the exporter state. +`wp-admin/admin.php?page=wpcom-migration-manual` (`administrator` role, single-site only). It is a debugging screen, and nothing links to it. It holds the credential the host verifies: on a host with `openssl_verify()`, a public key textarea (*Enroll key*, PEM or one line, replacing any enrolled key) and a table with the enrolled key's id and a *Remove* button; elsewhere, the export secret form and a *Remove secret* button. Below it, the export URL and the WordPress.com blog ID. Deactivating the plugin disconnects and discards both credentials. ## How WordPress.com provisions the exporter -Two lanes end at the same three routes: `POST /wp-json/wpcom-migration/v1/reprint/install-public-key {"public_key"}` → `{ "key_id", "export_url" }`, `POST …/rotate-export-secret` → `{ "secret", "export_url" }` and `POST …/enable-export` → `{ "enabled_at", "export_url" }`. All require an administrator, authenticated either by core (an application password) or by a Jetpack user token, and all answer 501 on a network. Reprint transfers over https only. +WordPress.com reaches the site in one of two ways, described below: with an application password, or through a Jetpack connection. Either way it calls one of two routes: `POST /wp-json/wpcom-migration/v1/reprint/install-public-key {"public_key"}` → `{ "key_id", "export_url" }` or `POST …/rotate-export-secret` → `{ "secret", "export_url" }`. Both require an administrator, authenticated by core (an application password) or by a Jetpack user token, and both answer 501 on a network. -Which credential WordPress.com sets up depends on the host. Where `openssl_verify()` exists, Reprint signs with keys: WordPress.com installs its public key (PEM or one line of base64, RSA, 3072 bits or more; anything else is a 400 `wpcom_migration_invalid_public_key`), and `rotate-export-secret` answers 501 `wpcom_migration_secret_auth_unsupported`. Elsewhere it rotates the secret, and `install-public-key` answers 501 `wpcom_migration_key_auth_unsupported`. The site holds one key at a time; installing another replaces it. `enable-export` answers 409 `wpcom_migration_no_credential` until either credential is stored and valid. Export requests are verified by Reprint's `RequestAuthenticator`, which accepts whichever of the two the host supports. +Which credential WordPress.com sets up depends on the host. Where `openssl_verify()` exists, Reprint signs with keys: WordPress.com installs its public key (PEM or one line of base64, RSA, 3072 bits or more; anything else is a 400 `wpcom_migration_invalid_public_key`), and `rotate-export-secret` answers 501 `wpcom_migration_secret_auth_unsupported`. Elsewhere it rotates the secret, and `install-public-key` answers 501 `wpcom_migration_key_auth_unsupported`. The site holds one key at a time; installing another replaces it. The exporter is on while either credential is stored and valid; there is no separate switch and no time limit, since the plugin is removed once the migration is done. Export requests are verified by Reprint's `RequestAuthenticator`, which accepts whichever of the two the host supports. -**Application password** — where `GET /wp-json/` lists `authentication.application-passwords` (core: `is_ssl()` true and no plugin has switched them off). WordPress.com sends the administrator once to `wp-admin/authorize-application.php?app_name=Migrate+to+WordPress.com&app_id=&success_url=…&reject_url=…`; core redirects to `success_url` with `site_url`, `user_login` and `password`. WordPress.com then checks the password with `GET /wp-json/wp/v2/users/me` (a 401 here means the server strips the `Authorization` header — Apache CGI without the rewrite rule WordPress 5.6 added; saving Permalinks regenerates it), installs and activates the plugin (`POST /wp-json/wp/v2/plugins {"slug":"wpcom-migration","status":"active"}`; an older copy is replaced by `PUT …/plugins/wpcom-migration/wpcom_migration {"status":"inactive"}`, `DELETE`, then the `POST`, since core has no update route), calls the two routes, exports, and revokes the password (`GET …/users/me/application-passwords/introspect`, then `DELETE …/application-passwords/`). No login on the site. +**Application password** — where `GET /wp-json/` lists `authentication.application-passwords` (core: `is_ssl()` true and no plugin has switched them off). WordPress.com sends the administrator once to `wp-admin/authorize-application.php?app_name=Migrate+to+WordPress.com&app_id=&success_url=…&reject_url=…`; core redirects to `success_url` with `site_url`, `user_login` and `password`. WordPress.com then checks the password with `GET /wp-json/wp/v2/users/me` (a 401 here means the server strips the `Authorization` header — Apache CGI without the rewrite rule WordPress 5.6 added; saving Permalinks regenerates it), installs and activates the plugin (`POST /wp-json/wp/v2/plugins {"slug":"wpcom-migration","status":"active"}`; an older copy is replaced by `PUT …/plugins/wpcom-migration/wpcom_migration {"status":"inactive"}`, `DELETE`, then the `POST`, since core has no update route), calls the route for the host's credential, exports, and revokes the password (`GET …/users/me/application-passwords/introspect`, then `DELETE …/application-passwords/`). No login on the site. -**Jetpack connection** — everywhere else, including sites where Wordfence or another security plugin has disabled application passwords. The administrator installs the plugin from the plugin directory and presses *Connect WordPress.com* on the screen; the connection package registers the site and sends them to WordPress.com to authorize, then back. A site that already runs Jetpack is connected at once for the account that connected Jetpack — the connection is shared. WordPress.com then calls the routes signed with the user token. - -Connection events fire `wpcom_migration_connection_event`, exporter events `wpcom_migration_reprint_export_event`; none carries a token, secret or signature. +**Jetpack connection** — everywhere else, including sites where Wordfence or another security plugin has disabled application passwords. The administrator installs the plugin from the plugin directory and presses *Connect WordPress.com* on the screen; the connection package registers the site and sends them to WordPress.com to authorize, then back. A site that already runs Jetpack is connected at once for the account that connected Jetpack — the connection is shared. WordPress.com then calls the route signed with the user token. ## Checks @@ -79,11 +76,9 @@ Publishing to wp.org is not automated. ## Known limitations -- Running this plugin next to `reprint-server-wp` is unsupported. Both ship the same package; a request that loads classes from both copies can fatal on the package's path-required function files. - The reprint client appends `&reprint-api` to any URL that lacks it. If `reprint-server-wp` is active and loads first, it answers on `reprint-api` before this plugin runs. - Sites on placeholder salts get the write veto but not the salt binding: `wp_salt()` stores its own salt in `wp_options`, where whoever can write the credential can read it. -- The connection package requires PHP 7.4, so the plugin does too. -- Reprint transfers over https only; a site that cannot serve https cannot be migrated this way. +- The Jetpack connection package (`automattic/jetpack-connection`) requires PHP 7.4, so the plugin does too. - `wp/v2/plugins` installs from wordpress.org by slug only, so the version with the exporter must be published there before WordPress.com can install it. ## Security diff --git a/plugin/composer.json b/plugin/composer.json index 28845f56..98eec8ba 100644 --- a/plugin/composer.json +++ b/plugin/composer.json @@ -8,7 +8,7 @@ "automattic/jetpack-autoloader": "^6", "automattic/jetpack-config": "^4.0", "automattic/jetpack-connection": "^9.1", - "wp-php-toolkit/reprint-server": "0.10.13" + "wp-php-toolkit/reprint-server": "0.11.0" }, "autoload": { "classmap": [ diff --git a/plugin/composer.lock b/plugin/composer.lock index dd243a08..4048e695 100644 --- a/plugin/composer.lock +++ b/plugin/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4b89aa46ef6e3fecde290380e8e01762", + "content-hash": "b0f08b481b4e392fc2bd7939f2b63053", "packages": [ { "name": "automattic/jetpack-a8c-mc-stats", @@ -650,16 +650,16 @@ }, { "name": "wp-php-toolkit/reprint-server", - "version": "v0.10.13", + "version": "v0.11.0", "source": { "type": "git", - "url": "https://github.com/wp-php-toolkit/reprint-server", - "reference": "2b298f2af6d15bc34d51ad4bc965d4a4c51765ea" + "url": "https://github.com/wp-php-toolkit/reprint-server.git", + "reference": "00e4dd6812dad5fcd5aed85878322c1f08023af5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/wp-php-toolkit/reprint-server/zipball/2b298f2af6d15bc34d51ad4bc965d4a4c51765ea", - "reference": "2b298f2af6d15bc34d51ad4bc965d4a4c51765ea", + "url": "https://api.github.com/repos/wp-php-toolkit/reprint-server/zipball/00e4dd6812dad5fcd5aed85878322c1f08023af5", + "reference": "00e4dd6812dad5fcd5aed85878322c1f08023af5", "shasum": "" }, "require": { @@ -684,7 +684,11 @@ "GPL-2.0-or-later" ], "description": "Streaming export engine used by the Reprint Server WordPress plugin", - "time": "2026-10-01T13:01:25+00:00" + "support": { + "issues": "https://github.com/wp-php-toolkit/reprint-server/issues", + "source": "https://github.com/wp-php-toolkit/reprint-server/tree/v0.11.0" + }, + "time": "2026-10-08T19:38:46+00:00" } ], "packages-dev": [], diff --git a/plugin/connection/class-connect-page.php b/plugin/connection/class-connect-page.php index d4bd8a45..870c9833 100644 --- a/plugin/connection/class-connect-page.php +++ b/plugin/connection/class-connect-page.php @@ -232,7 +232,7 @@ public function render_disconnect_link() {

- +

get_current_user_id() ) ); @@ -270,8 +248,6 @@ private static function discard_credential( $option, $hash_option, $event ) { /** * Stores a shared secret together with its salt-keyed hash. * - * Saving a secret does not touch the export window. - * * @param string $secret The new secret. * @return bool Whether the secret and a matching hash are now stored. */ @@ -295,7 +271,7 @@ public static function store_secret( $secret ) { * key stored before. * * Takes the one-line form PublicKeyServer::assert_valid_public_key() - * returns. Saving a key touches neither the secret nor the export window. + * returns. Saving a key leaves any secret in place. * * @param string $public_key The one-line public key. * @return bool Whether the key and a matching hash are now stored. @@ -320,8 +296,8 @@ public static function store_public_key( $public_key ) { * stores its own salt in wp_options, where whoever can write the credential * can read it, so the hashes add no protection there. * - * The option name prefixes the message, so copying the window timestamp - * and its hash into the secret options does not make a secret that + * The option name prefixes the message, so copying one credential and + * its hash into the other's options does not make a credential that * verifies. * * @param string $hash_option The option the hash is stored in. @@ -349,60 +325,20 @@ private static function credential_hash_matches( $hash_option, $value ) { return hash_equals( self::compute_credential_hash( $hash_option, $value ), $stored_hash ); } - /** - * Whether the current export window is open. - * - * @param int|null $now Unix time to compare against, or null for now. - * @return bool - */ - public static function is_export_window_open( $now = null ) { - $enabled_at = (int) get_option( self::ENABLED_OPTION, 0 ); - $now = null === $now ? time() : (int) $now; - - return $enabled_at > 0 - && $enabled_at <= $now + self::CLOCK_SKEW - && ( $now - $enabled_at ) <= HOUR_IN_SECONDS - && self::credential_hash_matches( self::ENABLED_HASH_OPTION, $enabled_at ); - } - - /** - * Opens the export window by stamping the enabled option with the current - * time and hashing the stamp. - * - * @return int The unix time the window was opened at. - */ - public static function open_export_window() { - $now = time(); - - // Value then hash: a crash between them leaves a mismatch, which reads - // as closed. Each skips an unchanged value, so a busy client costs at - // most two writes per elapsed second. - self::write_option( self::ENABLED_OPTION, $now ); - self::write_option( self::ENABLED_HASH_OPTION, self::compute_credential_hash( self::ENABLED_HASH_OPTION, $now ) ); - - return $now; - } - - /** - * Closes the export window. - */ - public static function close_export_window() { - delete_option( self::ENABLED_OPTION ); - delete_option( self::ENABLED_HASH_OPTION ); - } - /** * The state the screens render. * + * The credential is the one this host verifies: the public key where + * Reprint requires keys, the secret elsewhere. The other one, if stored, + * counts for nothing. + * * @return array { - * @type bool $has_secret Whether a secret is stored. - * @type bool $secret_valid Whether its hash matches under the current salt. - * @type bool $has_public_key Whether a public key is stored. - * @type bool $public_key_valid Whether its hash matches under the current salt. - * @type bool $has_credential Whether either is stored. - * @type bool $credential_valid Whether either is valid. - * @type bool $window_open Whether the export window is open. - * @type int|null $window_expires_at Unix time the window lapses, or null when closed. + * @type bool $has_secret Whether a secret is stored. + * @type bool $secret_valid Whether its hash matches under the current salt. + * @type bool $has_public_key Whether a public key is stored. + * @type bool $public_key_valid Whether its hash matches under the current salt. + * @type bool $has_credential Whether the credential is stored. + * @type bool $credential_valid Whether it is valid, which turns the exporter on. * } */ public static function get_state() { @@ -412,17 +348,15 @@ public static function get_state() { $public_key = get_option( self::PUBLIC_KEY_OPTION, '' ); $has_public_key = is_string( $public_key ) && '' !== $public_key; $public_key_valid = $has_public_key && self::credential_hash_matches( self::PUBLIC_KEY_HASH_OPTION, $public_key ); - $window_open = self::is_export_window_open(); + $key_auth = Utils::key_auth_required(); return array( - 'has_secret' => $has_secret, - 'secret_valid' => $secret_valid, - 'has_public_key' => $has_public_key, - 'public_key_valid' => $public_key_valid, - 'has_credential' => $has_secret || $has_public_key, - 'credential_valid' => $secret_valid || $public_key_valid, - 'window_open' => $window_open, - 'window_expires_at' => $window_open ? (int) get_option( self::ENABLED_OPTION, 0 ) + HOUR_IN_SECONDS : null, + 'has_secret' => $has_secret, + 'secret_valid' => $secret_valid, + 'has_public_key' => $has_public_key, + 'public_key_valid' => $public_key_valid, + 'has_credential' => $key_auth ? $has_public_key : $has_secret, + 'credential_valid' => $key_auth ? $public_key_valid : $secret_valid, ); } @@ -446,9 +380,7 @@ public function handle_request() { // Any origin: the client may run in a browser (Playground) from // deployments we cannot know ahead of time, and origin is no boundary // when every request must be signed anyway. Preflights come before - // the signature check because browsers send them without credentials, and - // before the window check so a client whose window has closed can - // reach the 409 below. + // the signature check because browsers send them without credentials. // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash $request_method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( $_SERVER['REQUEST_METHOD'] ) : ''; if ( 'OPTIONS' === $request_method ) { @@ -460,25 +392,16 @@ public function handle_request() { return; } - // Without a valid signature a closed window answers nothing, so an idle - // site stays indistinguishable from one that never had the feature. - $state = self::get_state(); - $window_open = $state['window_open']; - + // Without a credential the request answers nothing, so a site that was + // never set up stays indistinguishable from one without the feature. + $state = self::get_state(); if ( ! $state['has_credential'] ) { - if ( ! $window_open ) { - return; - } - $this->error( 503, 'Export not configured. Start the migration on WordPress.com.' ); return; } // A credential this class did not hash under the current salt is no // credential at all, so it never reaches signature verification. if ( ! $state['credential_valid'] ) { - if ( ! $window_open ) { - return; - } self::record_event( 'credential_hash_mismatch' ); $this->error( 503, 'Export credential invalidated: the stored credential does not match this site\'s salts. Start the migration again on WordPress.com.' ); return; @@ -494,27 +417,14 @@ public function handle_request() { $auth_failure = $this->verify_signature( $secret, $public_keys ); if ( null !== $auth_failure ) { - if ( ! $window_open ) { - return; - } - // Reprint's client maps the reason to its message, and reads - // not_configured as an answer only from a 503. - $status = RequestAuthenticator::REASON_NOT_CONFIGURED === $auth_failure['reason'] ? 503 : 403; + // Reprint's client maps the reason to its message, and reads the + // two not-set-up reasons as an answer only from a 503. + $unconfigured = array( RequestAuthenticator::REASON_NOT_CONFIGURED, RequestAuthenticator::REASON_NO_KEYS_ENROLLED ); + $status = in_array( $auth_failure['reason'], $unconfigured, true ) ? 503 : 403; $this->error( $status, $auth_failure['message'], $auth_failure['reason'] ); return; } - // Signature checks out, so say which state this is: still here, only - // needing re-arming, rather than gone. - if ( ! $window_open ) { - $this->error( 409, 'Export window closed. Enable the exporter on the settings screen.' ); - return; - } - - // An export spans many requests and can run past the hour, so keep the - // window open while a client is working. - self::open_export_window(); - try { $this->serve_export(); } catch ( \InvalidArgumentException $exception ) { @@ -602,6 +512,9 @@ protected function send_cors_headers() { /** * Sends a JSON error response and terminates. * + * The body carries Reprint's auth_version, without which its client + * reports the plugin as outdated. + * * @param int $code HTTP status code. * @param string $message Error description. * @param string|null $reason Machine-readable reason code, or null for none. @@ -612,8 +525,9 @@ protected function error( $code, $message, $reason = null ) { 'reason' => $message, ); $body = array( - 'error' => $message, - 'code' => $code, + 'error' => $message, + 'code' => $code, + 'auth_version' => Utils::AUTH_VERSION, ); if ( null !== $reason ) { $context['reason_code'] = $reason; diff --git a/plugin/reprint/class-manual-page.php b/plugin/reprint/class-manual-page.php index 34785cf4..067f1953 100644 --- a/plugin/reprint/class-manual-page.php +++ b/plugin/reprint/class-manual-page.php @@ -1,9 +1,9 @@ authorize( self::SAVE_SECRET_ACTION ); + if ( Utils::key_auth_required() ) { + $this->redirect_with_notice( 'secret_unsupported' ); + } + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Checked in authorize(). $secret = sanitize_text_field( wp_unslash( $_POST[ self::SECRET_FIELD ] ?? '' ) ); @@ -240,31 +228,7 @@ public function handle_save_secret() { } /** - * Turns the exporter on or off and redirects back with a result notice. - */ - public function handle_save_enabled() { - $this->authorize( self::SAVE_ENABLED_ACTION ); - - $state = Exporter::get_state(); - if ( ! $state['credential_valid'] ) { - $this->redirect_with_notice( 'not_configured' ); - } - - // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Checked in authorize(). - if ( isset( $_POST[ self::ENABLED_FIELD ] ) ) { - Exporter::open_export_window(); - Exporter::record_event( 'window_opened', array( 'user_id' => get_current_user_id() ) ); - $this->redirect_with_notice( 'enabled' ); - } - - Exporter::close_export_window(); - Exporter::record_event( 'window_closed', array( 'user_id' => get_current_user_id() ) ); - $this->redirect_with_notice( 'disabled' ); - } - - /** - * Removes the secret, turns the exporter off, and redirects back with a - * result notice. + * Removes the secret and redirects back with a result notice. */ public function handle_discard_secret() { $this->authorize( self::DISCARD_SECRET_ACTION ); @@ -315,8 +279,7 @@ public function handle_save_public_key() { } /** - * Removes the public key, turns the exporter off, and redirects back with - * a result notice. + * Removes the public key and redirects back with a result notice. */ public function handle_remove_public_key() { $this->authorize( self::REMOVE_PUBLIC_KEY_ACTION ); @@ -346,10 +309,10 @@ private function authorize( $action ) { /** * Redirects back to the screen with a result code and exits. * - * @param string $result One of saved, unchanged, enabled, disabled, - * discarded, not_configured, storage_failure, - * key_saved, key_unchanged, key_removed, - * key_invalid, key_unsupported, + * @param string $result One of saved, unchanged, discarded, + * not_configured, storage_failure, + * secret_unsupported, key_saved, key_unchanged, + * key_removed, key_invalid, key_unsupported, * key_storage_failure. */ private function redirect_with_notice( $result ) { @@ -358,9 +321,9 @@ private function redirect_with_notice( $result ) { } /** - * Renders the screen: the secret form; the public key form and table; the - * exporter toggle and export URL once a credential is valid; the blog ID - * when connected. + * Renders the screen: the public key form and table where this host + * verifies keys, the secret form elsewhere; the export URL once a + * credential is valid; the blog ID when connected. */ public function render_page() { if ( ! in_array( 'administrator', wp_get_current_user()->roles, true ) ) { @@ -378,13 +341,14 @@ public function render_page() { echo '
'; $this->render_result_notice(); echo '

' . esc_html__( 'Set up Reprint manually', 'wpcom-migration' ) . '

'; - echo '

' . esc_html__( 'Support may ask you to set this up by hand.', 'wpcom-migration' ) . '

'; - $this->render_secret_form( $state ); - $this->render_public_key_section( $state ); + if ( Utils::key_auth_required() ) { + $this->render_public_key_section( $state ); + } else { + $this->render_secret_form( $state ); + } if ( $state['credential_valid'] ) { - $this->render_enable_form( $state ); $this->render_api_url(); } @@ -449,27 +413,19 @@ private function render_discard_form() { -

+

-

- ' . esc_html__( 'Public keys need OpenSSL, which this host does not have.', 'wpcom-migration' ) . '

'; - return; - } - ?>
@@ -511,31 +467,6 @@ private function render_public_key_section( array $state ) { -

- -
-

- - - - -

-

-
"> +

array( 'success', __( 'Secret saved.', 'wpcom-migration' ) ), 'unchanged' => array( 'success', __( 'The secret was already up to date.', 'wpcom-migration' ) ), - 'enabled' => array( 'success', __( 'Exporter on for the next hour.', 'wpcom-migration' ) ), - 'disabled' => array( 'success', __( 'Exporter off.', 'wpcom-migration' ) ), - 'discarded' => array( 'success', __( 'Secret removed. The exporter is off.', 'wpcom-migration' ) ), + 'discarded' => array( 'success', __( 'Secret removed.', 'wpcom-migration' ) ), 'not_configured' => array( 'error', __( 'Enter an export secret first.', 'wpcom-migration' ) ), 'storage_failure' => array( 'error', __( 'The secret could not be saved.', 'wpcom-migration' ) ), + 'secret_unsupported' => array( 'error', __( 'This site verifies exports with public keys. Enroll a public key instead.', 'wpcom-migration' ) ), 'key_saved' => array( 'success', __( 'Public key saved.', 'wpcom-migration' ) ), 'key_unchanged' => array( 'success', __( 'That public key was already enrolled.', 'wpcom-migration' ) ), - 'key_removed' => array( 'success', __( 'Public key removed. The exporter is off.', 'wpcom-migration' ) ), + 'key_removed' => array( 'success', __( 'Public key removed.', 'wpcom-migration' ) ), 'key_invalid' => array( 'error', __( 'That is not a usable public key. Paste the RSA public key, 3072 bits or more, printed by "reprint keygen".', 'wpcom-migration' ) ), 'key_unsupported' => array( 'error', __( 'Public keys need OpenSSL, which this host does not have.', 'wpcom-migration' ) ), 'key_storage_failure' => array( 'error', __( 'The public key could not be saved.', 'wpcom-migration' ) ), diff --git a/plugin/reprint/class-rest-controller.php b/plugin/reprint/class-rest-controller.php index 6dd0921c..b44a7d6d 100644 --- a/plugin/reprint/class-rest-controller.php +++ b/plugin/reprint/class-rest-controller.php @@ -22,8 +22,7 @@ use WP_REST_Server; /** - * POST wpcom-migration/v1/reprint/rotate-export-secret, install-public-key and - * enable-export. + * POST wpcom-migration/v1/reprint/rotate-export-secret and install-public-key. */ class REST_Controller extends WP_REST_Controller { @@ -76,18 +75,6 @@ public function register_routes() { ), ) ); - - register_rest_route( - $this->namespace, - '/' . $this->rest_base . '/enable-export', - array( - array( - 'methods' => WP_REST_Server::CREATABLE, - 'callback' => array( $this, 'enable_export' ), - 'permission_callback' => array( $this, 'permission_check' ), - ), - ) - ); } /** @@ -197,43 +184,6 @@ public function install_public_key( WP_REST_Request $request ) { ); } - /** - * Opens the export window without rotating the secret, so a caller that - * already has a credential can reopen a window that closed. - * - * @return WP_REST_Response|WP_Error The unix time the window opened at. - */ - public function enable_export() { - $state = Exporter::get_state(); - if ( ! $state['credential_valid'] ) { - return new WP_Error( - 'wpcom_migration_no_credential', - __( 'Install a public key or save a secret first.', 'wpcom-migration' ), - array( 'status' => 409 ) - ); - } - - $enabled_at = Exporter::open_export_window(); - - if ( ! Exporter::is_export_window_open() ) { - return new WP_Error( - 'wpcom_migration_window_not_opened', - __( 'Failed to open the export window.', 'wpcom-migration' ), - array( 'status' => 500 ) - ); - } - - Exporter::record_event( 'window_opened', array( 'user_id' => get_current_user_id() ) ); - - return new WP_REST_Response( - array( - 'enabled_at' => $enabled_at, - 'export_url' => $this->export_url(), - ), - 200 - ); - } - /** * An administrator, authenticated by a Jetpack user token or by core. * diff --git a/plugin/reprint/class-settings-page.php b/plugin/reprint/class-settings-page.php index dd493f3f..7dd344c3 100644 --- a/plugin/reprint/class-settings-page.php +++ b/plugin/reprint/class-settings-page.php @@ -48,7 +48,7 @@ class Settings_Page { const MODE_BROKEN = 'broken'; /** - * A credential is valid and the exporter is on. + * A credential is valid, so the exporter is on. * * @var string */ @@ -61,13 +61,6 @@ class Settings_Page { */ const MODE_CONNECTED_WAITING = 'connected_waiting'; - /** - * WordPress.com installed a credential; the exporter is off; no user connection. - * - * @var string - */ - const MODE_PROVISIONED_WAITING = 'provisioned_waiting'; - /** * Nothing set up yet. * @@ -262,7 +255,7 @@ public static function mode( array $state, $user_connected ) { return self::MODE_BROKEN; } - if ( $state['credential_valid'] && $state['window_open'] ) { + if ( $state['credential_valid'] ) { return self::MODE_READY; } @@ -270,10 +263,6 @@ public static function mode( array $state, $user_connected ) { return self::MODE_CONNECTED_WAITING; } - if ( $state['credential_valid'] ) { - return self::MODE_PROVISIONED_WAITING; - } - return self::MODE_NEEDS_CONNECTING; } @@ -306,9 +295,8 @@ private function render_mode( $mode, $user_connected ) { return; case self::MODE_READY: - $this->render_pill_and_heading( __( 'Site ready to migrate', 'wpcom-migration' ), 'info' ); - $this->render_sentence( __( 'Your site is now ready to be copied by WordPress.com. Your site stays online and unchanged while it\'s copied.', 'wpcom-migration' ) ); - $this->render_sentence( __( 'Keep this plugin active until the migration is done. We email you when it\'s finished.', 'wpcom-migration' ) ); + $this->render_pill_and_heading( __( 'Site ready to migrate', 'wpcom-migration' ), 'success' ); + $this->render_ready_to_migrate(); if ( null !== $section ) { $section->render_continue_button(); } @@ -323,14 +311,6 @@ private function render_mode( $mode, $user_connected ) { } return; - case self::MODE_PROVISIONED_WAITING: - $this->render_pill_and_heading( __( 'Set up by WordPress.com', 'wpcom-migration' ), 'info' ); - $this->render_ready_to_migrate(); - if ( null !== $section ) { - $section->render_continue_button(); - } - return; - case self::MODE_NEEDS_CONNECTING: default: $this->render_pill_and_heading(); @@ -357,16 +337,17 @@ private function render_pill_and_heading( $pill = '', $tone = 'info' ) { } /** - * Renders the sentence the two waiting modes share. + * Renders the paragraphs the ready and connected modes share. */ private function render_ready_to_migrate() { $this->render_sentence( sprintf( /* translators: %s: the site's address, without the scheme. */ - __( '%s is ready to migrate. The migration runs from WordPress.com, so there\'s nothing else to do here.', 'wpcom-migration' ), + __( '%s is ready to migrate. The migration runs from WordPress.com, so there\'s nothing else to do here. Your site stays online and unchanged while it\'s copied.', 'wpcom-migration' ), untrailingslashit( preg_replace( '#^https?://#i', '', home_url() ) ) ) ); + $this->render_sentence( __( 'Keep this plugin active until the migration is done. We\'ll email you when it\'s finished.', 'wpcom-migration' ) ); } /** @@ -375,6 +356,6 @@ private function render_ready_to_migrate() { * @param string $text Plain text. */ private function render_sentence( $text ) { - echo '

' . esc_html( $text ) . '

'; + echo '

' . esc_html( wptexturize( $text ) ) . '

'; } } diff --git a/tests/e2e/blueprint-connection.json b/tests/e2e/blueprint-connection.json index 72cf6783..a256b990 100644 --- a/tests/e2e/blueprint-connection.json +++ b/tests/e2e/blueprint-connection.json @@ -92,11 +92,7 @@ }, { "step": "runPHP", - "code": "get_error_message() . \"\\n\" ); exit( 1 ); }" + }, + { + "step": "defineWpConfigConsts", + "consts": { + "WP_ALLOW_MULTISITE": true, + "MULTISITE": true, + "SUBDOMAIN_INSTALL": false, + "DOMAIN_CURRENT_SITE": "localhost", + "PATH_CURRENT_SITE": "/", + "SITE_ID_CURRENT_SITE": 1, + "BLOG_ID_CURRENT_SITE": 1 + } }, { "step": "runPHP", - "code": "get_error_message() . \"\\n\" ); exit( 1 ); }" + "code": "get_error_message() . \"\\n\" ); exit( 1 ); }" }, { "step": "runPHP", diff --git a/tests/e2e/blueprint-open.json b/tests/e2e/blueprint-no-credential.json similarity index 57% rename from tests/e2e/blueprint-open.json rename to tests/e2e/blueprint-no-credential.json index 8699543d..c4797766 100644 --- a/tests/e2e/blueprint-open.json +++ b/tests/e2e/blueprint-no-credential.json @@ -9,10 +9,6 @@ { "step": "activatePlugin", "pluginPath": "wpcom-migration/wpcom_migration.php" - }, - { - "step": "runPHP", - "code": "getMessage(); throw $error; }" + }, + { + "step": "runPHP", + "code": "getMessage(); throw $error; }" - }, - { - "step": "runPHP", - "code": "get_data(); - if ( ! isset( $data['enabled_at'] ) || ! is_int( $data['enabled_at'] ) || $data['enabled_at'] < time() - 60 ) { - throw new RuntimeException( "Step '$step': expected a fresh enabled_at, got: " . wp_json_encode( $data ) ); - } - if ( ! isset( $data['export_url'] ) ) { - throw new RuntimeException( "Step '$step': enable-export must also carry export_url." ); - } - $state = Exporter::get_state(); - if ( ! $state['window_open'] ) { - throw new RuntimeException( "Step '$step': enabling should open the window: " . wp_json_encode( $state ) ); - } - break; - - case 'render-connected-and-enabled': + case 'render-connected-and-ready': wpcom_migration_e2e_expect_mode( Settings_Page::MODE_READY, $step ); $html = wpcom_migration_e2e_render_connect_page(); wpcom_migration_e2e_expect_contains( $html, 'Site ready to migrate', $step ); @@ -286,14 +270,6 @@ function ( $entry ) { } break; - case 'swap-in-known-secret': - // request.php signs with a fixed secret; the random one never leaves - // Playground. The window opened through REST stays open. - if ( ! Exporter::store_secret( 'smoke-secret-0123456789abcdef0123456789abcdef' ) ) { - throw new RuntimeException( "Step '$step': could not store the known secret." ); - } - break; - case 'render-not-connected': wpcom_migration_e2e_expect_mode( Settings_Page::MODE_NEEDS_CONNECTING, $step ); $html = wpcom_migration_e2e_render_connect_page(); @@ -386,13 +362,14 @@ function ( $location ) { wpcom_migration_e2e_expect_mode( Settings_Page::MODE_CONNECTED_WAITING, $step ); $html = wpcom_migration_e2e_render_connect_page(); wpcom_migration_e2e_expect_contains( $html, 'wpcom-migration-pill--success">Connected to WordPress.com

', $step ); - wpcom_migration_e2e_expect_contains( $html, untrailingslashit( preg_replace( '#^https?://#i', '', home_url() ) ) . ' is ready to migrate.', $step ); + wpcom_migration_e2e_expect_contains( $html, untrailingslashit( preg_replace( '#^https?://#i', '', home_url() ) ) . ' is ready to migrate. The migration runs from WordPress.com, so there’s nothing else to do here. Your site stays online and unchanged while it’s copied.', $step ); + wpcom_migration_e2e_expect_contains( $html, 'Keep this plugin active until the migration is done. We’ll email you when it’s finished.', $step ); wpcom_migration_e2e_expect_not_contains( $html, 'e2e@example.com', $step ); wpcom_migration_e2e_expect_contains( $html, 'Go to WordPress.com', $step ); wpcom_migration_e2e_expect_contains( $html, 'href="https://my.wordpress.com/sites"', $step ); wpcom_migration_e2e_expect_primary_count( $html, 1, $step ); wpcom_migration_e2e_expect_contains( $html, 'value="' . Connect_Page::DISCONNECT_ACTION . '"', $step ); - wpcom_migration_e2e_expect_contains( $html, 's connection to WordPress.com.', $step ); + wpcom_migration_e2e_expect_contains( $html, '’s connection to WordPress.com.', $step ); wpcom_migration_e2e_expect_not_contains( $html, (string) WPCOM_MIGRATION_E2E_BLOG_ID, $step ); wpcom_migration_e2e_expect_not_contains( $html, 'Connect WordPress.com', $step ); @@ -415,7 +392,7 @@ function ( $location ) { * determine_current_user runs Rest_Authentication (nonce, timestamp, token, * signature), then dispatches in-process. Only the HTTP hop is skipped. * - * @param string $route REST route, e.g. /wpcom-migration/v1/reprint/enable-export. + * @param string $route REST route, e.g. /wpcom-migration/v1/reprint/install-public-key. * @param string $access_token "key.secret" for a blog token, "key.secret.user_id" for a user token. * @param int $user_id 0 for a blog token. * @param array $params REST parameters. diff --git a/tests/e2e/request.php b/tests/e2e/request.php index 5d3fd986..440a865c 100644 --- a/tests/e2e/request.php +++ b/tests/e2e/request.php @@ -5,9 +5,9 @@ * * Usage: php tests/e2e/request.php * - * Scenarios: open, closed, secret-hash-deleted, enabled-hash-deleted, screen, - * provisioning-hmac, provisioning-key, connection, menu. The secret must match the one the matching - * blueprint stores. + * Scenarios: secret, no-credential, secret-hash-deleted, screen, screen-hmac, + * provisioning-hmac, provisioning-key, connection, menu. The secret must match + * the one the matching blueprint stores. * * @package wpcom-migration */ @@ -32,69 +32,69 @@ require_once __DIR__ . '/provisioning-steps.php'; switch ( $wpcom_migration_scenario ) { - case 'open': - wpcom_migration_e2e_assert_open( $wpcom_migration_endpoint, wpcom_migration_e2e_signed_headers( $wpcom_migration_secret ) ); - break; + case 'secret': + // A stored secret is all it takes: a signed request is served. + wpcom_migration_e2e_assert_open( $wpcom_migration_endpoint, wpcom_migration_e2e_signed_headers( $wpcom_migration_secret, $wpcom_migration_endpoint ) ); - case 'screen': - // The screen blueprint ends with the window open (secret valid, - // enabled); the same signed-preflight assertions prove the screen's - // form handlers left the exporter in a working state. - wpcom_migration_e2e_assert_open( $wpcom_migration_endpoint, wpcom_migration_e2e_signed_headers( $wpcom_migration_secret ) ); + // Unsigned: refused, in the code the client maps to its message. + $response = wpcom_migration_e2e_request( $wpcom_migration_endpoint, array() ); + wpcom_migration_e2e_expect_status( $response, 403 ); + wpcom_migration_e2e_expect_error_json( $response, 403, 'missing_header' ); + + // A client from before Reprint 0.11 sends X-Auth-Content-Hash and is + // told to update. + $response = wpcom_migration_e2e_request( $wpcom_migration_endpoint, array_merge( wpcom_migration_e2e_signed_headers( $wpcom_migration_secret, $wpcom_migration_endpoint ), array( 'X-Auth-Content-Hash: irrelevant' ) ) ); + wpcom_migration_e2e_expect_status( $response, 403 ); + wpcom_migration_e2e_expect_error_json( $response, 403, 'client_update_required' ); break; + case 'screen': + case 'screen-hmac': case 'menu': // The blueprint's runPHP steps are the whole test; there is nothing // to ask the site over HTTP. break; - case 'closed': - // Unsigned with the window closed: the request falls through to + case 'no-credential': + // Nothing stored: signed or not, the request falls through to // WordPress. The plain front page, no JSON, no export headers. - $response = wpcom_migration_e2e_request( $wpcom_migration_endpoint, array() ); - wpcom_migration_e2e_expect_status( $response, 200 ); - if ( false === stripos( $response['body'], ' $unsigned, + 'Signed' => $signed, + ) as $label => $response ) { + wpcom_migration_e2e_expect_status( $response, 200 ); + if ( false === stripos( $response['body'], 'get_auth_headers( '' ) as $name => $value ) { + foreach ( $client->get_auth_headers( 'GET', $url ) as $name => $value ) { $lines[] = $name . ': ' . $value; } return $lines; @@ -472,8 +433,8 @@ function wpcom_migration_e2e_expect_json( array $response ) { } /** - * Fails unless the body is the exporter's error JSON with the given code, - * and the given reason when one is named. + * Fails unless the body is the exporter's error JSON with the given code and + * Reprint's auth_version, and the given reason when one is named. * * @param array $response Response from wpcom_migration_e2e_request(). * @param int $code Expected 'code' value. @@ -484,6 +445,9 @@ function wpcom_migration_e2e_expect_error_json( array $response, $code, $reason if ( ! isset( $json['code'], $json['error'] ) || $code !== (int) $json['code'] ) { wpcom_migration_e2e_fail( sprintf( 'Expected error JSON with code %d, got: %s', $code, $response['body'] ) ); } + if ( ! isset( $json['auth_version'] ) || 2 !== $json['auth_version'] ) { + wpcom_migration_e2e_fail( 'Expected error JSON with auth_version 2, got: ' . $response['body'] ); + } if ( null !== $reason && ( ! isset( $json['reason'] ) || $reason !== $json['reason'] ) ) { wpcom_migration_e2e_fail( sprintf( 'Expected error JSON with reason %s, got: %s', $reason, $response['body'] ) ); } diff --git a/tests/e2e/run.sh b/tests/e2e/run.sh index a40c00f3..b6aeb572 100755 --- a/tests/e2e/run.sh +++ b/tests/e2e/run.sh @@ -1,13 +1,14 @@ #!/usr/bin/env bash # # Boots the built plugin in WordPress Playground once per credential state -# and checks the export endpoint's answers, then once more to drive the -# settings screen through its form handlers, and twice more to provision +# and checks the export endpoint's answers, then twice more to drive the +# screens through their form handlers (once with a public key, once with a +# shared secret), and twice more to provision # through the REST routes with application passwords: once with a shared # secret and once with a public key. # -# The multisite scenario runs its blueprint without a server: Playground -# refuses to enable multisite on a custom port, and its steps need no HTTP. +# The multisite scenario runs its blueprint without a server: multisite +# does not support custom ports, and its steps need no HTTP. # # Usage: tests/e2e/run.sh # e.g. tests/e2e/run.sh build/wpcom-migration @@ -32,7 +33,7 @@ PORT="${E2E_PORT:-9400}" BASE_URL="http://127.0.0.1:$PORT" PLAYGROUND_CLI="${PLAYGROUND_CLI:-npx --yes @wp-playground/cli@3.1.54}" # shellcheck disable=SC2206 # A space-separated scenario list, split on purpose. -SCENARIOS=(${E2E_SCENARIOS:-open closed secret-hash-deleted enabled-hash-deleted screen provisioning-hmac provisioning-key connection menu multisite}) +SCENARIOS=(${E2E_SCENARIOS:-secret no-credential secret-hash-deleted screen screen-hmac provisioning-hmac provisioning-key connection menu multisite}) for command_name in php npx; do if ! command -v "$command_name" >/dev/null 2>&1; then diff --git a/tests/e2e/screen-steps.php b/tests/e2e/screen-steps.php index 0e72a713..e1a55110 100644 --- a/tests/e2e/screen-steps.php +++ b/tests/e2e/screen-steps.php @@ -69,6 +69,25 @@ function wpcom_migration_e2e_screen_step( $step ) { wpcom_migration_e2e_expect_not_contains( $html, 'id="wpcom-migration-reprint-secret"', $step ); break; + case 'render-secret-form': + $manual_html = wpcom_migration_e2e_render_manual( $manual ); + wpcom_migration_e2e_expect_contains( $manual_html, 'id="wpcom-migration-reprint-secret"', $step ); + wpcom_migration_e2e_expect_contains( $manual_html, '

Export secret

', $step ); + wpcom_migration_e2e_expect_not_contains( $manual_html, 'name="' . Manual_Page::PUBLIC_KEY_FIELD . '"', $step ); + wpcom_migration_e2e_expect_not_contains( $manual_html, '

Public key

', $step ); + wpcom_migration_e2e_expect_not_contains( $manual_html, 'Public keys need OpenSSL', $step ); + break; + + case 'assert-secret-refused': + $state = Exporter::get_state(); + if ( $state['has_secret'] ) { + throw new RuntimeException( "Step '$step': a host that verifies keys must not store a secret: " . wp_json_encode( $state ) ); + } + $_GET[ Manual_Page::NOTICE_QUERY_ARG ] = 'secret_unsupported'; + $manual_html = wpcom_migration_e2e_render_manual( $manual ); + wpcom_migration_e2e_expect_contains( $manual_html, 'This site verifies exports with public keys.', $step ); + break; + case 'save-secret-empty': wpcom_migration_e2e_post( Manual_Page::SAVE_SECRET_ACTION, array( Manual_Page::SECRET_FIELD => '' ) ); $manual->handle_save_secret(); // Redirects and exits. @@ -88,13 +107,15 @@ function wpcom_migration_e2e_screen_step( $step ) { case 'assert-secret-saved': $state = Exporter::get_state(); - if ( ! $state['secret_valid'] || $state['window_open'] ) { - throw new RuntimeException( 'Saving the secret should store a valid secret and leave the window closed: ' . wp_json_encode( $state ) ); + if ( ! $state['secret_valid'] ) { + throw new RuntimeException( 'Saving the secret should store a valid secret: ' . wp_json_encode( $state ) ); } - wpcom_migration_e2e_expect_mode( Settings_Page::MODE_PROVISIONED_WAITING, $step ); + wpcom_migration_e2e_expect_mode( Settings_Page::MODE_READY, $step ); $html = wpcom_migration_e2e_render( $page ); - wpcom_migration_e2e_expect_contains( $html, 'wpcom-migration-pill--info">Set up by WordPress.com

', $step ); - wpcom_migration_e2e_expect_contains( $html, 'is ready to migrate. The migration runs from WordPress.com', $step ); + wpcom_migration_e2e_expect_contains( $html, 'wpcom-migration-pill--success">Site ready to migrate

', $step ); + wpcom_migration_e2e_expect_contains( $html, untrailingslashit( preg_replace( '#^https?://#i', '', home_url() ) ) . ' is ready to migrate. The migration runs from WordPress.com, so there’s nothing else to do here. Your site stays online and unchanged while it’s copied.', $step ); + wpcom_migration_e2e_expect_contains( $html, 'Keep this plugin active until the migration is done. We’ll email you when it’s finished.', $step ); + wpcom_migration_e2e_expect_not_contains( $html, 'Your site is now ready to be copied', $step ); wpcom_migration_e2e_expect_contains( $html, 'Go to WordPress.com', $step ); wpcom_migration_e2e_expect_contains( $html, 'href="https://my.wordpress.com/sites"', $step ); wpcom_migration_e2e_expect_not_contains( $html, 'Connect WordPress.com', $step ); @@ -102,10 +123,7 @@ function wpcom_migration_e2e_screen_step( $step ) { wpcom_migration_e2e_expect_primary_count( $html, 1, $step ); wpcom_migration_e2e_expect_not_contains( $html, 'id="wpcom-migration-reprint-api-url"', $step ); wpcom_migration_e2e_expect_not_contains( $html, '

Export secret

', $step ); - wpcom_migration_e2e_expect_not_contains( $html, 'Turn the exporter on', $step ); - wpcom_migration_e2e_expect_not_contains( $html, 'Exporter disabled', $step ); wpcom_migration_e2e_expect_not_contains( $html, 'Shared secret', $step ); - wpcom_migration_e2e_expect_not_contains( $html, 'Export window', $step ); wpcom_migration_e2e_expect_not_contains( $html, 'Remote API URL', $step ); $manual_html = wpcom_migration_e2e_render_manual( $manual ); @@ -114,9 +132,9 @@ function wpcom_migration_e2e_screen_step( $step ) { wpcom_migration_e2e_expect_contains( $manual_html, 'id="wpcom-migration-reprint-api-url"', $step ); wpcom_migration_e2e_expect_contains( $manual_html, esc_attr( home_url( '?' . Exporter::QUERY_VAR ) ), $step ); wpcom_migration_e2e_expect_contains( $manual_html, '

Export secret

', $step ); - wpcom_migration_e2e_expect_contains( $manual_html, 'Turn the exporter on', $step ); wpcom_migration_e2e_expect_contains( $manual_html, '

Export URL

', $step ); wpcom_migration_e2e_expect_contains( $manual_html, 'Remove secret', $step ); + wpcom_migration_e2e_expect_not_contains( $manual_html, 'name="' . Manual_Page::PUBLIC_KEY_FIELD . '"', $step ); break; case 'reject-non-administrator-manual-access': @@ -161,7 +179,6 @@ function wpcom_migration_e2e_screen_step( $step ) { try { $actions = array( array( Manual_Page::SAVE_SECRET_ACTION, 'handle_save_secret', array( Manual_Page::SECRET_FIELD => 'attacker-secret' ) ), - array( Manual_Page::SAVE_ENABLED_ACTION, 'handle_save_enabled', array( Manual_Page::ENABLED_FIELD => '1' ) ), array( Manual_Page::DISCARD_SECRET_ACTION, 'handle_discard_secret', array() ), ); foreach ( $actions as $action ) { @@ -182,8 +199,8 @@ function wpcom_migration_e2e_screen_step( $step ) { } $state = Exporter::get_state(); - if ( 'smoke-secret-0123456789abcdef0123456789abcdef' !== get_option( Exporter::SECRET_OPTION ) || ! $state['secret_valid'] || $state['window_open'] ) { - throw new RuntimeException( 'Non-administrator access changed exporter credentials or state.' ); + if ( 'smoke-secret-0123456789abcdef0123456789abcdef' !== get_option( Exporter::SECRET_OPTION ) || ! $state['secret_valid'] ) { + throw new RuntimeException( 'Non-administrator access changed exporter credentials.' ); } break; @@ -202,43 +219,6 @@ function wpcom_migration_e2e_screen_step( $step ) { wpcom_migration_e2e_expect_not_contains( $html, 'Invalid: the site', $step ); break; - case 'enable': - wpcom_migration_e2e_post( Manual_Page::SAVE_ENABLED_ACTION, array( Manual_Page::ENABLED_FIELD => '1' ) ); - $manual->handle_save_enabled(); - break; - - case 'assert-enabled': - $state = Exporter::get_state(); - if ( ! $state['window_open'] ) { - throw new RuntimeException( 'Enabling should open the window: ' . wp_json_encode( $state ) ); - } - wpcom_migration_e2e_expect_mode( Settings_Page::MODE_READY, $step ); - $html = wpcom_migration_e2e_render( $page ); - wpcom_migration_e2e_expect_contains( $html, 'wpcom-migration-pill--info">Site ready to migrate

', $step ); - wpcom_migration_e2e_expect_contains( $html, 'Your site is now ready to be copied by WordPress.com', $step ); - wpcom_migration_e2e_expect_contains( $html, 'Keep this plugin active until the migration is done', $step ); - wpcom_migration_e2e_expect_contains( $html, 'Go to WordPress.com', $step ); - wpcom_migration_e2e_expect_primary_count( $html, 1, $step ); - wpcom_migration_e2e_expect_not_contains( $html, 'Connect WordPress.com', $step ); - wpcom_migration_e2e_expect_not_contains( $html, 'Set up by WordPress.com', $step ); - break; - - case 'disable': - wpcom_migration_e2e_post( Manual_Page::SAVE_ENABLED_ACTION, array() ); - $manual->handle_save_enabled(); - break; - - case 'assert-disabled': - $state = Exporter::get_state(); - if ( $state['window_open'] ) { - throw new RuntimeException( 'Disabling should close the window: ' . wp_json_encode( $state ) ); - } - wpcom_migration_e2e_expect_mode( Settings_Page::MODE_PROVISIONED_WAITING, $step ); - $html = wpcom_migration_e2e_render( $page ); - wpcom_migration_e2e_expect_contains( $html, 'Set up by WordPress.com', $step ); - wpcom_migration_e2e_expect_not_contains( $html, 'Site ready to migrate', $step ); - break; - case 'discard-secret': wpcom_migration_e2e_post( Manual_Page::DISCARD_SECRET_ACTION, array() ); $manual->handle_discard_secret(); @@ -246,8 +226,8 @@ function wpcom_migration_e2e_screen_step( $step ) { case 'assert-secret-discarded': $state = Exporter::get_state(); - if ( $state['has_secret'] || $state['has_public_key'] || $state['window_open'] ) { - throw new RuntimeException( 'Removing the secret should delete it and close the window: ' . wp_json_encode( $state ) ); + if ( $state['has_secret'] || $state['has_public_key'] ) { + throw new RuntimeException( 'Removing the secret should delete it: ' . wp_json_encode( $state ) ); } wpcom_migration_e2e_expect_mode( Settings_Page::MODE_NEEDS_CONNECTING, $step ); $_GET[ Manual_Page::NOTICE_QUERY_ARG ] = 'discarded'; @@ -257,7 +237,7 @@ function wpcom_migration_e2e_screen_step( $step ) { throw new RuntimeException( "Step '$step': the notice should sit above the page heading." ); } wpcom_migration_e2e_expect_not_contains( $manual_html, 'Remove secret', $step ); - wpcom_migration_e2e_expect_not_contains( $manual_html, 'Turn the exporter on', $step ); + wpcom_migration_e2e_expect_not_contains( $manual_html, 'wpcom-migration-reprint-api-url', $step ); break; case 'render-public-key-form': @@ -265,6 +245,8 @@ function wpcom_migration_e2e_screen_step( $step ) { wpcom_migration_e2e_expect_contains( $manual_html, 'name="' . Manual_Page::PUBLIC_KEY_FIELD . '"', $step ); wpcom_migration_e2e_expect_contains( $manual_html, 'Enroll key', $step ); wpcom_migration_e2e_expect_not_contains( $manual_html, 'wpcom-migration-reprint-key-table', $step ); + wpcom_migration_e2e_expect_not_contains( $manual_html, 'id="wpcom-migration-reprint-secret"', $step ); + wpcom_migration_e2e_expect_not_contains( $manual_html, '

Export secret

', $step ); break; case 'enroll-invalid-public-key': @@ -291,18 +273,18 @@ function wpcom_migration_e2e_screen_step( $step ) { case 'assert-public-key-enrolled': $public_key = get_option( 'wpcom_migration_e2e_public_key' ); $state = Exporter::get_state(); - if ( get_option( Exporter::PUBLIC_KEY_OPTION ) !== $public_key || ! $state['public_key_valid'] || $state['has_secret'] || $state['window_open'] ) { - throw new RuntimeException( "Step '$step': expected the enrolled key, no secret, window closed: " . wp_json_encode( $state ) ); + if ( get_option( Exporter::PUBLIC_KEY_OPTION ) !== $public_key || ! $state['public_key_valid'] || $state['has_secret'] ) { + throw new RuntimeException( "Step '$step': expected the enrolled key and no secret: " . wp_json_encode( $state ) ); } - wpcom_migration_e2e_expect_mode( Settings_Page::MODE_PROVISIONED_WAITING, $step ); + wpcom_migration_e2e_expect_mode( Settings_Page::MODE_READY, $step ); $_GET[ Manual_Page::NOTICE_QUERY_ARG ] = 'key_saved'; $manual_html = wpcom_migration_e2e_render_manual( $manual ); wpcom_migration_e2e_expect_contains( $manual_html, 'Public key saved.', $step ); wpcom_migration_e2e_expect_contains( $manual_html, 'wpcom-migration-reprint-key-table', $step ); wpcom_migration_e2e_expect_contains( $manual_html, '' . \WordPress\Reprint\Server\Utils::public_key_fingerprint( $public_key ) . '', $step ); wpcom_migration_e2e_expect_contains( $manual_html, 'value="' . Manual_Page::REMOVE_PUBLIC_KEY_ACTION . '"', $step ); - wpcom_migration_e2e_expect_contains( $manual_html, 'Turn the exporter on', $step ); wpcom_migration_e2e_expect_contains( $manual_html, 'wpcom-migration-reprint-api-url', $step ); + wpcom_migration_e2e_expect_not_contains( $manual_html, 'id="wpcom-migration-reprint-secret"', $step ); wpcom_migration_e2e_expect_not_contains( $manual_html, 'Remove secret', $step ); break; @@ -313,8 +295,8 @@ function wpcom_migration_e2e_screen_step( $step ) { case 'assert-public-key-removed': $state = Exporter::get_state(); - if ( $state['has_public_key'] || $state['window_open'] ) { - throw new RuntimeException( "Step '$step': removing the key should delete it and close the window: " . wp_json_encode( $state ) ); + if ( $state['has_public_key'] ) { + throw new RuntimeException( "Step '$step': removing the key should delete it: " . wp_json_encode( $state ) ); } $_GET[ Manual_Page::NOTICE_QUERY_ARG ] = 'key_removed'; $manual_html = wpcom_migration_e2e_render_manual( $manual ); @@ -322,13 +304,6 @@ function wpcom_migration_e2e_screen_step( $step ) { wpcom_migration_e2e_expect_not_contains( $manual_html, 'wpcom-migration-reprint-key-table', $step ); break; - case 'assert-secret-removed-key-kept': - $state = Exporter::get_state(); - if ( $state['has_secret'] || ! $state['public_key_valid'] || $state['window_open'] ) { - throw new RuntimeException( "Step '$step': removing the secret should keep the key and close the window: " . wp_json_encode( $state ) ); - } - break; - case 'invalidate-public-key': delete_option( Exporter::PUBLIC_KEY_HASH_OPTION ); $state = Exporter::get_state(); @@ -340,6 +315,28 @@ function wpcom_migration_e2e_screen_step( $step ) { wpcom_migration_e2e_expect_contains( $html, 'no longer matches this site', $step ); break; + case 'assert-other-credential-ignored': + // A credential this host does not verify, stored before the manual + // screen stopped offering it, must not read as ready. + if ( \WordPress\Reprint\Server\Utils::key_auth_required() ) { + $stored = Exporter::store_secret( $secret ); + } else { + list( , $public_key ) = \WordPress\Reprint\Server\PublicKeyClient::generate_keypair(); + $stored = Exporter::store_public_key( $public_key ); + } + if ( ! $stored ) { + throw new RuntimeException( "Step '$step': could not store the other credential." ); + } + $state = Exporter::get_state(); + if ( $state['has_credential'] || $state['credential_valid'] ) { + throw new RuntimeException( "Step '$step': the other credential must count for nothing: " . wp_json_encode( $state ) ); + } + wpcom_migration_e2e_expect_mode( Settings_Page::MODE_NEEDS_CONNECTING, $step ); + wpcom_migration_e2e_expect_not_contains( wpcom_migration_e2e_render( $page ), 'Site ready to migrate', $step ); + wpcom_migration_e2e_expect_not_contains( wpcom_migration_e2e_render_manual( $manual ), 'wpcom-migration-reprint-api-url', $step ); + Exporter::discard_credentials(); + break; + case 'assert-no-mode-headings': $html = wpcom_migration_e2e_render( $page ); wpcom_migration_e2e_expect_not_contains( $html, 'wpcom-migration-mode', $step );