From 3bbc9b550c2416913b3a1d1d24ad9728a89fe0db Mon Sep 17 00:00:00 2001
From: Fredrik Rombach Ekelund
Date: Mon, 12 Oct 2026 09:30:40 +0200
Subject: [PATCH 01/11] Update Reprint to 0.11.0 and drop the export window
Reprint 0.11.0 refuses shared secrets on hosts with OpenSSL and expects
every error body to carry auth_version 2. The exporter adds it, and answers
no_keys_enrolled with a 503, as it does not_configured.
The plugin is installed for one migration and removed after it, and
Reprint now verifies public keys, so the hour-long export window adds
nothing. A valid credential turns the exporter on: the enabled options,
the enable-export route, the manual on/off checkbox and the "Set up by
WordPress.com" mode are gone. The "Site ready to migrate" mode takes the
site address sentence from it.
Co-Authored-By: Claude Opus 5.5
---
README.md | 15 +-
plugin/composer.json | 2 +-
plugin/composer.lock | 18 +-
plugin/reprint/class-exporter.php | 159 ++++--------------
plugin/reprint/class-manual-page.php | 99 ++---------
plugin/reprint/class-rest-controller.php | 52 +-----
plugin/reprint/class-settings-page.php | 55 +++---
tests/e2e/blueprint-connection.json | 10 +-
tests/e2e/blueprint-enabled-hash-deleted.json | 18 --
...open.json => blueprint-no-credential.json} | 4 -
tests/e2e/blueprint-screen.json | 30 +---
tests/e2e/blueprint-secret-hash-deleted.json | 6 +-
...rint-closed.json => blueprint-secret.json} | 6 +-
tests/e2e/connection-steps.php | 32 +---
tests/e2e/request.php | 155 +++++++----------
tests/e2e/run.sh | 2 +-
tests/e2e/screen-steps.php | 81 +++------
17 files changed, 187 insertions(+), 557 deletions(-)
delete mode 100644 tests/e2e/blueprint-enabled-hash-deleted.json
rename tests/e2e/{blueprint-open.json => blueprint-no-credential.json} (57%)
rename tests/e2e/{blueprint-closed.json => blueprint-secret.json} (65%)
diff --git a/README.md b/README.md
index 91e59b2f..e4f79cb3 100644
--- a/README.md
+++ b/README.md
@@ -32,9 +32,8 @@ To activate a source checkout directly (without a build), run `composer install
|---|---|---|
| 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.
@@ -49,17 +48,17 @@ On a network the old screen keeps its row in the network admin, and the *Setting
## 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). 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 export URL and the WordPress.com blog ID. Removing either credential leaves the other in place. 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.
+Two lanes end at the same two routes: `POST /wp-json/wpcom-migration/v1/reprint/install-public-key {"public_key"}` → `{ "key_id", "export_url" }` and `POST …/rotate-export-secret` → `{ "secret", "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.
-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.
+**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.
Connection events fire `wpcom_migration_connection_event`, exporter events `wpcom_migration_reprint_export_event`; none carries a token, secret or signature.
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/reprint/class-exporter.php b/plugin/reprint/class-exporter.php
index c70921d3..0887c75e 100644
--- a/plugin/reprint/class-exporter.php
+++ b/plugin/reprint/class-exporter.php
@@ -1,6 +1,6 @@
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,16 @@ 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.
*
* @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 either is stored.
+ * @type bool $credential_valid Whether either is valid, which turns the exporter on.
* }
*/
public static function get_state() {
@@ -412,17 +344,14 @@ 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();
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' => $has_secret || $has_public_key,
+ 'credential_valid' => $secret_valid || $public_key_valid,
);
}
@@ -446,9 +375,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 +387,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 +412,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 +507,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 +520,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..274aa209 100644
--- a/plugin/reprint/class-manual-page.php
+++ b/plugin/reprint/class-manual-page.php
@@ -1,7 +1,6 @@
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 +274,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,11 +304,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,
- * key_storage_failure.
+ * @param string $result One of saved, unchanged, discarded,
+ * not_configured, storage_failure, key_saved,
+ * key_unchanged, key_removed, key_invalid,
+ * key_unsupported, key_storage_failure.
*/
private function redirect_with_notice( $result ) {
wp_safe_redirect( add_query_arg( self::NOTICE_QUERY_ARG, $result, self::page_url() ) );
@@ -359,8 +316,7 @@ 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.
+ * 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 ) ) {
@@ -384,7 +340,6 @@ public function render_page() {
$this->render_public_key_section( $state );
if ( $state['credential_valid'] ) {
- $this->render_enable_form( $state );
$this->render_api_url();
}
@@ -449,7 +404,7 @@ private function render_discard_form() {
-
+
-
-
-
-
-
">
+
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' ) ),
'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..abe3fe24 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;
}
@@ -307,8 +296,14 @@ private function render_mode( $mode, $user_connected ) {
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_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. Your site stays online and unchanged while it\'s copied.', 'wpcom-migration' ),
+ self::site_address()
+ )
+ );
+ $this->render_sentence( __( 'Keep this plugin active until the migration is done. We\'ll email you when it\'s finished.', 'wpcom-migration' ) );
if ( null !== $section ) {
$section->render_continue_button();
}
@@ -316,21 +311,19 @@ private function render_mode( $mode, $user_connected ) {
case self::MODE_CONNECTED_WAITING:
$this->render_pill_and_heading( __( 'Connected to WordPress.com', 'wpcom-migration' ), 'success' );
- $this->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' ),
+ self::site_address()
+ )
+ );
if ( null !== $section ) {
$section->render_continue_button();
$section->render_disconnect_link();
}
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 +350,12 @@ private function render_pill_and_heading( $pill = '', $tone = 'info' ) {
}
/**
- * Renders the sentence the two waiting modes share.
+ * The site's address, without the scheme.
+ *
+ * @return string
*/
- 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' ),
- untrailingslashit( preg_replace( '#^https?://#i', '', home_url() ) )
- )
- );
+ private static function site_address() {
+ return untrailingslashit( preg_replace( '#^https?://#i', '', home_url() ) );
}
/**
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_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();
@@ -415,7 +391,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..a889dc18 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,
+ * provisioning-hmac, provisioning-key, connection, menu. The secret must match
+ * the one the matching blueprint stores.
*
* @package wpcom-migration
*/
@@ -32,69 +32,68 @@
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 '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 +432,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 +444,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..afceff81 100755
--- a/tests/e2e/run.sh
+++ b/tests/e2e/run.sh
@@ -32,7 +32,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 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..c923530d 100644
--- a/tests/e2e/screen-steps.php
+++ b/tests/e2e/screen-steps.php
@@ -88,13 +88,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--info">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 +104,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, '
', $step );
wpcom_migration_e2e_expect_contains( $manual_html, 'Remove secret', $step );
break;
@@ -161,7 +159,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 +179,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 +199,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 +206,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 +217,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':
@@ -291,17 +251,16 @@ 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, 'Remove secret', $step );
break;
@@ -313,8 +272,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 );
@@ -324,9 +283,11 @@ function wpcom_migration_e2e_screen_step( $step ) {
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 ) );
+ if ( $state['has_secret'] || ! $state['public_key_valid'] ) {
+ throw new RuntimeException( "Step '$step': removing the secret should keep the key: " . wp_json_encode( $state ) );
}
+ // The key alone keeps the exporter on.
+ wpcom_migration_e2e_expect_mode( Settings_Page::MODE_READY, $step );
break;
case 'invalidate-public-key':
From c7412a46f848cf886fabff66aaf9c282339fa073 Mon Sep 17 00:00:00 2001
From: Fredrik Rombach Ekelund
Date: Mon, 12 Oct 2026 09:47:03 +0200
Subject: [PATCH 02/11] Show only the host's credential on the manual screen
Where Reprint requires keys, the screen offers the public key form and
refuses a posted secret; elsewhere it offers the secret form, as it
already refused a posted key. The screen e2e scenario splits in two:
screen for keys, screen-hmac for secrets.
Co-Authored-By: Claude Opus 5.5
---
README.md | 4 +-
plugin/reprint/class-manual-page.php | 37 ++++++++-------
tests/e2e/blueprint-screen-hmac.json | 70 ++++++++++++++++++++++++++++
tests/e2e/blueprint-screen.json | 64 +------------------------
tests/e2e/request.php | 3 +-
tests/e2e/run.sh | 7 +--
tests/e2e/screen-steps.php | 32 +++++++++----
7 files changed, 123 insertions(+), 94 deletions(-)
create mode 100644 tests/e2e/blueprint-screen-hmac.json
diff --git a/README.md b/README.md
index e4f79cb3..8c6f1a26 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
@@ -48,7 +48,7 @@ On a network the old screen keeps its row in the network admin, and the *Setting
## 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 export URL and the WordPress.com blog ID. Removing either credential leaves the other in place. Deactivating the plugin disconnects and discards both credentials.
+`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 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
diff --git a/plugin/reprint/class-manual-page.php b/plugin/reprint/class-manual-page.php
index 274aa209..326e1bb2 100644
--- a/plugin/reprint/class-manual-page.php
+++ b/plugin/reprint/class-manual-page.php
@@ -1,6 +1,7 @@
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 ] ?? '' ) );
@@ -305,9 +310,10 @@ private function authorize( $action ) {
* Redirects back to the screen with a result code and exits.
*
* @param string $result One of saved, unchanged, discarded,
- * not_configured, storage_failure, key_saved,
- * key_unchanged, key_removed, key_invalid,
- * key_unsupported, key_storage_failure.
+ * 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 ) {
wp_safe_redirect( add_query_arg( self::NOTICE_QUERY_ARG, $result, self::page_url() ) );
@@ -315,8 +321,9 @@ private function redirect_with_notice( $result ) {
}
/**
- * Renders the screen: the secret form; the public key form and table; the
- * 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 ) ) {
@@ -336,8 +343,11 @@ public function render_page() {
echo '