Skip to content

Connection: protected owner class #43601

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 25 commits into from
Jun 4, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

Adding support for protected connection owner.
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
<?php
/**
* The Jetpack Connection Protected Owner Error Handler class file.
*
* @package wpcomsh
*/

namespace Automattic\WPComSH\Connection;

/**
* The Jetpack Connection Protected Owner Error Handler class.
*
* This class handles errors related to protected owner accounts in the Jetpack Connection.
* It retrieves owner account errors stored in WordPress options and displays them in the UI.
*
* The class automatically clears errors when the required local account is created,
* allowing external healing code to establish the proper Jetpack connection.
*
* Additionally, this class provides email prepopulation functionality for the WordPress
* user creation form when creating missing protected owner accounts. It overrides the
* default User_Admin class behavior to ensure the WP.com invitation checkbox is not
* pre-checked when creating protected owner accounts.
*
* @since $$next-version$$
*/
class Protected_Owner_Error_Handler {

/**
* The name of the option that stores the error
*
* @var string
*/
const STORED_ERRORS_OPTION = 'jetpack_connection_protected_owner_error';

/**
* Holds the instance of this singleton class
*
* @var Protected_Owner_Error_Handler $instance
*/
private static $instance = null;

/**
* Initialize instance and register hooks
*/
private function __construct() {
// Inject protected owner errors into the connection error system
add_filter( 'jetpack_connection_get_verified_errors', array( $this, 'handle_error' ) );

// Clear errors when the missing user is created or updated (allows external healing code to work)
add_action( 'user_register', array( $this, 'check_and_clear_error_for_user' ) );
add_action( 'profile_update', array( $this, 'check_and_clear_error_for_user' ) );

// Add form prepopulation functionality
add_action( 'user_new_form', array( $this, 'prepopulate_user_form' ) );

// Disable WordPress.com invitations when creating protected owner accounts
add_filter( 'jetpack_sso_invite_new_users_wpcom', array( $this, 'disable_wpcom_invite_for_protected_owner' ) );
}

/**
* Gets the instance of this singleton class
*
* @return Protected_Owner_Error_Handler $instance
*/
public static function get_instance() {
if ( self::$instance === null ) {
self::$instance = new self();
}
return self::$instance;
}

/**
* Check if there's an active protected owner error
*
* @return array|false Raw error data if there's an active error, false otherwise.
*/
private function get_active_error() {
// Check if option is populated
$raw_error = get_option( self::STORED_ERRORS_OPTION, false );

// Return early if no error is stored
if ( ! $raw_error || ! is_array( $raw_error ) ) {
return false;
}

// Validate the minimal required fields
if ( ! isset( $raw_error['error_type'] ) || ! isset( $raw_error['email'] ) ) {
return false;
}

// Check if user exists with the required email
$user = get_user_by( 'email', $raw_error['email'] );
if ( $user ) {
// User exists, delete the option and return false (no active error)
$this->delete_error();
return false;
}

// User doesn't exist, we have an active error
return $raw_error;
}

/**
* Handle protected owner errors in the connection error system
*
* @param array $verified_errors Current verified errors.
* @return array Updated verified errors including protected owner errors.
*/
public function handle_error( $verified_errors ) {
// Clear all existing errors first
$verified_errors = array();

$raw_error = $this->get_active_error();

// Return early if no active error
if ( ! $raw_error ) {
return $verified_errors;
}

// Use a consistent error code for all protected owner errors
$error_code = 'protected_owner_missing';

// Prepare error data for the connection error system
$user_id = '0';
$timestamp = $raw_error['timestamp'] ?? time();

$error_details = array(
'error_code' => $error_code,
'user_id' => $user_id,
'error_message' => $this->get_error_message( $raw_error['email'] ),
'error_data' => array(
'email' => $raw_error['email'],
'error_type' => $raw_error['error_type'],
'action' => 'create_missing_account',
'support_url' => admin_url( 'user-new.php' ),
),
'timestamp' => $timestamp,
'nonce' => wp_generate_password( 10, false ),
'error_type' => 'protected_owner',
);

// Return only the protected owner error - it takes priority over other connection errors
// since it's typically the root cause and other errors may be symptoms
return array(
$error_code => array(
$user_id => $error_details,
),
);
}

/**
* Get a user-friendly error message for protected owner errors
*
* @param string $email The WordPress.com email address of the protected owner.
* @return string The error message.
*/
private function get_error_message( $email ) {
return sprintf(
/* translators: %s is the WordPress.com email address */
__( 'This site needs to be connected to WordPress.com by the plan owner account with email %s. Please create a local user account with this email address to resolve this issue.', 'wpcomsh' ),
esc_html( $email )
);
}

/**
* Delete the stored error
*/
public function delete_error() {
delete_option( self::STORED_ERRORS_OPTION );
}

/**
* Check if the user matches the protected owner error and clear it if so
* This allows external healing code to automatically establish the connection
*
* @param int $user_id The ID of the user to check.
*/
public function check_and_clear_error_for_user( $user_id ) {
// Get the raw error data to check the email
$raw_error = get_option( self::STORED_ERRORS_OPTION, false );

// Return early if no error is stored
if ( ! $raw_error || ! is_array( $raw_error ) || ! isset( $raw_error['email'] ) ) {
return;
}

// Get the user
$user = get_user_by( 'id', $user_id );
if ( ! $user ) {
return;
}

// Check if the user's email matches the required email
if ( strtolower( $user->user_email ) === strtolower( $raw_error['email'] ) ) {
// The user with the required email has been created/updated
// Clear the error so external healing code can establish the connection
$this->delete_error();
}
}

/**
* Add form prepopulation functionality
*/
public function prepopulate_user_form() {
$email = $this->get_prepopulation_email();

if ( ! $email ) {
return;
}

// Output hidden field and JavaScript to prepopulate the form
?>
<input type="hidden" id="jetpack_prepopulate_email" value="<?php echo esc_attr( $email ); ?>" />
<input type="hidden" name="jetpack_create_missing_account" value="1" />

<script type="text/javascript">
(function() {
document.addEventListener('DOMContentLoaded', function() {
// Prepopulate the email field and role
var emailInput = document.getElementById('jetpack_prepopulate_email');
if (emailInput && emailInput.value) {
var emailField = document.getElementById('email');
var roleField = document.getElementById('role');

if (emailField) {
emailField.value = emailInput.value;
}
if (roleField) {
roleField.value = 'administrator';
}
}
});
})();
</script>
<?php
}

/**
* Get the email address for prepopulation from various sources
*
* @return string|false Email address if available, false otherwise
*/
private function get_prepopulation_email() {
// Check URL parameters first (from React component)
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- URL parameters are read-only for prepopulation, no sensitive actions performed
if ( isset( $_GET['jetpack_protected_owner_email'] ) &&
isset( $_GET['jetpack_create_missing_account'] ) ) {
$email = sanitize_email( wp_unslash( $_GET['jetpack_protected_owner_email'] ) );
if ( is_email( $email ) ) {
return $email;
}
}
// phpcs:enable WordPress.Security.NonceVerification.Recommended

// Only prepopulate when explicitly triggered from dashboard
return false;
}

/**
* Disable WordPress.com invitations when creating protected owner accounts
*
* @param bool $invite_new_users_wpcom Whether to invite new users to WordPress.com.
* @return bool Updated value indicating whether to invite new users to WordPress.com.
*/
public function disable_wpcom_invite_for_protected_owner( $invite_new_users_wpcom ) {
// Check if we're in a protected owner creation context
$email = $this->get_prepopulation_email();
if ( ! $email ) {
return $invite_new_users_wpcom; // Not a protected owner creation, let the default behavior proceed
}

// Disable invitations for protected owner creation
return false;
}
}
22 changes: 22 additions & 0 deletions projects/plugins/wpcomsh/connection/protected-owner-handlers.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php
/**
* Protected Owner error handling initialization for wpcomsh.
*
* This file loads and initializes the Protected Owner error handling
* that integrates with the Jetpack connection error system.
*
* @package wpcomsh
*/

// Require the Protected Owner Error Handler class
require_once __DIR__ . '/class-protected-owner-error-handler.php';

// Initialize the Protected Owner error handler
add_action(
'plugins_loaded',
function () {
// Initialize the Protected Owner Error Handler singleton
\Automattic\WPComSH\Connection\Protected_Owner_Error_Handler::get_instance();
},
5
);
454 changes: 454 additions & 0 deletions projects/plugins/wpcomsh/tests/ProtectedOwnerErrorHandlerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,454 @@
<?php
/**
* Protected Owner Error Handler Test file.
*
* @package wpcomsh
*/

use Automattic\WPComSH\Connection\Protected_Owner_Error_Handler;

/**
* Class ProtectedOwnerErrorHandlerTest.
*/
class ProtectedOwnerErrorHandlerTest extends WP_UnitTestCase {
use \Automattic\Jetpack\PHPUnit\WP_UnitTestCase_Fix;

/**
* The Protected_Owner_Error_Handler instance being tested.
*
* @var Protected_Owner_Error_Handler
*/
private $handler;

/**
* Set up test environment before each test.
*/
public function setUp(): void {
parent::setUp();
$this->handler = Protected_Owner_Error_Handler::get_instance();

// Clean up any existing error data
delete_option( Protected_Owner_Error_Handler::STORED_ERRORS_OPTION );
delete_option( 'jetpack_connection_xmlrpc_verified_errors' );
}

/**
* Clean up after each test.
*/
public function tearDown(): void {
delete_option( Protected_Owner_Error_Handler::STORED_ERRORS_OPTION );
delete_option( 'jetpack_connection_xmlrpc_verified_errors' );
parent::tearDown();
}

/**
* Test that the class implements singleton pattern correctly.
*/
public function test_singleton_pattern() {
$instance1 = Protected_Owner_Error_Handler::get_instance();
$instance2 = Protected_Owner_Error_Handler::get_instance();

$this->assertSame( $instance1, $instance2 );
$this->assertInstanceOf( Protected_Owner_Error_Handler::class, $instance1 );
}

/**
* Test handle_error returns original errors when no error is stored.
*/
public function test_handle_error_returns_original_errors_when_no_error_stored() {
$original_errors = array( 'some_error' => array( '1' => array( 'data' => 'test' ) ) );
$result = $this->handler->handle_error( $original_errors );
$this->assertEquals( array(), $result );
}

/**
* Test handle_error returns original errors for invalid data.
*/
public function test_handle_error_returns_original_errors_for_invalid_data() {
$original_errors = array( 'some_error' => array( '1' => array( 'data' => 'test' ) ) );

// Test with non-array data
update_option( Protected_Owner_Error_Handler::STORED_ERRORS_OPTION, 'invalid_data' );
$result = $this->handler->handle_error( $original_errors );
$this->assertEquals( array(), $result );

// Test with missing error_type
update_option( Protected_Owner_Error_Handler::STORED_ERRORS_OPTION, array( 'email' => 'test@example.com' ) );
$result = $this->handler->handle_error( $original_errors );
$this->assertEquals( array(), $result );

// Test with missing email
update_option( Protected_Owner_Error_Handler::STORED_ERRORS_OPTION, array( 'error_type' => 'missing_owner' ) );
$result = $this->handler->handle_error( $original_errors );
$this->assertEquals( array(), $result );
}

/**
* Test handle_error returns original errors when user exists.
*/
public function test_handle_error_returns_original_errors_when_user_exists() {
$test_email = 'test@example.com';
$original_errors = array( 'some_error' => array( '1' => array( 'data' => 'test' ) ) );

// Create a user with the required email
$this->factory()->user->create( array( 'user_email' => $test_email ) );

// Set up an error
update_option(
Protected_Owner_Error_Handler::STORED_ERRORS_OPTION,
array(
'error_type' => 'missing_owner',
'email' => $test_email,
)
);

$result = $this->handler->handle_error( $original_errors );

// Should return empty array and delete the stored error
$this->assertEquals( array(), $result );
$this->assertFalse( get_option( Protected_Owner_Error_Handler::STORED_ERRORS_OPTION ) );
}

/**
* Test handle_error returns protected owner error when user doesn't exist.
*/
public function test_handle_error_returns_protected_owner_error() {
$test_email = 'test@example.com';
$test_timestamp = time();
$original_errors = array( 'some_error' => array( '1' => array( 'data' => 'test' ) ) );

update_option(
Protected_Owner_Error_Handler::STORED_ERRORS_OPTION,
array(
'error_type' => 'missing_owner',
'email' => $test_email,
'timestamp' => $test_timestamp,
)
);

$result = $this->handler->handle_error( $original_errors );

// Should return only the protected owner error (takes priority)
$this->assertIsArray( $result );
$this->assertArrayHasKey( 'protected_owner_missing', $result );
$this->assertArrayNotHasKey( 'some_error', $result );

$error_data = $result['protected_owner_missing']['0'];
$this->assertEquals( 'protected_owner_missing', $error_data['error_code'] );
$this->assertSame( '0', $error_data['user_id'] );
$this->assertEquals( 'protected_owner', $error_data['error_type'] );
$this->assertEquals( $test_timestamp, $error_data['timestamp'] );
$this->assertArrayHasKey( 'error_message', $error_data );
$this->assertStringContainsString( $test_email, $error_data['error_message'] );
$this->assertArrayHasKey( 'error_data', $error_data );
$this->assertEquals( $test_email, $error_data['error_data']['email'] );
$this->assertEquals( 'missing_owner', $error_data['error_data']['error_type'] );
$this->assertEquals( 'create_missing_account', $error_data['error_data']['action'] );
$this->assertStringContainsString( 'user-new.php', $error_data['error_data']['support_url'] );
}

/**
* Test delete_error method.
*/
public function test_delete_error() {
// Set an error first
update_option(
Protected_Owner_Error_Handler::STORED_ERRORS_OPTION,
array(
'error_type' => 'missing_owner',
'email' => 'test@example.com',
)
);

// Verify error exists
$this->assertNotFalse( get_option( Protected_Owner_Error_Handler::STORED_ERRORS_OPTION ) );

// Delete the error
$this->handler->delete_error();

// Verify our error is gone
$this->assertFalse( get_option( Protected_Owner_Error_Handler::STORED_ERRORS_OPTION ) );
}

/**
* Test check_and_clear_error_for_user method with matching email.
*/
public function test_check_and_clear_error_for_user_matching_email() {
$test_email = 'test@example.com';

// Set an error
update_option(
Protected_Owner_Error_Handler::STORED_ERRORS_OPTION,
array(
'error_type' => 'missing_owner',
'email' => $test_email,
)
);

// Create a user with matching email
$user_id = $this->factory()->user->create( array( 'user_email' => $test_email ) );

// Simulate user creation/update
$this->handler->check_and_clear_error_for_user( $user_id );

// Error should be cleared
$this->assertFalse( get_option( Protected_Owner_Error_Handler::STORED_ERRORS_OPTION ) );
}

/**
* Test check_and_clear_error_for_user method with non-matching email.
*/
public function test_check_and_clear_error_for_user_non_matching_email() {
$test_email = 'test@example.com';

// Set an error
update_option(
Protected_Owner_Error_Handler::STORED_ERRORS_OPTION,
array(
'error_type' => 'missing_owner',
'email' => $test_email,
)
);

// Create a user with different email
$user_id = $this->factory()->user->create( array( 'user_email' => 'different@example.com' ) );

// Simulate user creation/update
$this->handler->check_and_clear_error_for_user( $user_id );

// Error should remain
$this->assertNotFalse( get_option( Protected_Owner_Error_Handler::STORED_ERRORS_OPTION ) );
}

/**
* Test check_and_clear_error_for_user method with no error stored.
*/
public function test_check_and_clear_error_for_user_no_error_stored() {
// Create a user
$user_id = $this->factory()->user->create( array( 'user_email' => 'test@example.com' ) );

// This should not cause any errors
$this->handler->check_and_clear_error_for_user( $user_id );

// No error should be created
$this->assertFalse( get_option( Protected_Owner_Error_Handler::STORED_ERRORS_OPTION ) );
}

/**
* Test get_prepopulation_email from URL parameters.
*/
public function test_get_prepopulation_email_from_url_parameters() {
$test_email = 'test@example.com';

// Set up URL parameters
$_GET['jetpack_protected_owner_email'] = $test_email;
$_GET['jetpack_create_missing_account'] = '1';

// Use reflection to access private method
$reflection = new ReflectionClass( $this->handler );
$method = $reflection->getMethod( 'get_prepopulation_email' );
$method->setAccessible( true );

$result = $method->invoke( $this->handler );

$this->assertEquals( $test_email, $result );

// Clean up
unset( $_GET['jetpack_protected_owner_email'] );
unset( $_GET['jetpack_create_missing_account'] );
}

/**
* Test get_prepopulation_email from URL parameters with invalid email.
*/
public function test_get_prepopulation_email_from_url_parameters_invalid_email() {
// Set up URL parameters with invalid email
$_GET['jetpack_protected_owner_email'] = 'invalid-email';
$_GET['jetpack_create_missing_account'] = '1';

// Use reflection to access private method
$reflection = new ReflectionClass( $this->handler );
$method = $reflection->getMethod( 'get_prepopulation_email' );
$method->setAccessible( true );

$result = $method->invoke( $this->handler );

$this->assertFalse( $result );

// Clean up
unset( $_GET['jetpack_protected_owner_email'] );
unset( $_GET['jetpack_create_missing_account'] );
}

/**
* Test get_prepopulation_email from URL parameters missing create_missing_account.
*/
public function test_get_prepopulation_email_from_url_parameters_missing_create_flag() {
$test_email = 'test@example.com';

// Set up URL parameters missing the create flag
$_GET['jetpack_protected_owner_email'] = $test_email;

// Use reflection to access private method
$reflection = new ReflectionClass( $this->handler );
$method = $reflection->getMethod( 'get_prepopulation_email' );
$method->setAccessible( true );

$result = $method->invoke( $this->handler );

$this->assertFalse( $result );

// Clean up
unset( $_GET['jetpack_protected_owner_email'] );
}

/**
* Test get_prepopulation_email from stored error data fallback.
*/
public function test_get_prepopulation_email_from_stored_error() {
$test_email = 'test@example.com';

// Set up an error
update_option(
Protected_Owner_Error_Handler::STORED_ERRORS_OPTION,
array(
'error_type' => 'missing_owner',
'email' => $test_email,
)
);

// Use reflection to access private method
$reflection = new ReflectionClass( $this->handler );
$method = $reflection->getMethod( 'get_prepopulation_email' );
$method->setAccessible( true );

$result = $method->invoke( $this->handler );

// Should now return false since stored error fallback was removed
$this->assertFalse( $result );
}

/**
* Test get_prepopulation_email returns false when no email available.
*/
public function test_get_prepopulation_email_returns_false_when_no_email() {
// Use reflection to access private method
$reflection = new ReflectionClass( $this->handler );
$method = $reflection->getMethod( 'get_prepopulation_email' );
$method->setAccessible( true );

$result = $method->invoke( $this->handler );

$this->assertFalse( $result );
}

/**
* Test get_prepopulation_email URL parameters take priority over stored error.
*/
public function test_get_prepopulation_email_url_parameters_take_priority() {
$url_email = 'url@example.com';
$stored_email = 'stored@example.com';

// Set up stored error
update_option(
Protected_Owner_Error_Handler::STORED_ERRORS_OPTION,
array(
'error_type' => 'missing_owner',
'email' => $stored_email,
)
);

// Set up URL parameters (should work since only URL parameters are used)
$_GET['jetpack_protected_owner_email'] = $url_email;
$_GET['jetpack_create_missing_account'] = '1';

// Use reflection to access private method
$reflection = new ReflectionClass( $this->handler );
$method = $reflection->getMethod( 'get_prepopulation_email' );
$method->setAccessible( true );

$result = $method->invoke( $this->handler );

$this->assertEquals( $url_email, $result );

// Clean up
unset( $_GET['jetpack_protected_owner_email'] );
unset( $_GET['jetpack_create_missing_account'] );
}

/**
* Test prepopulate_user_form outputs expected HTML when email is available.
*/
public function test_prepopulate_user_form_with_email() {
$test_email = 'test@example.com';

// Set up URL parameters to ensure we have an email to prepopulate
$_GET['jetpack_protected_owner_email'] = $test_email;
$_GET['jetpack_create_missing_account'] = '1';

// Capture output
ob_start();
$this->handler->prepopulate_user_form();
$output = ob_get_clean();

// Verify output contains expected elements
$this->assertStringContainsString( $test_email, $output );
$this->assertStringContainsString( 'jetpack_prepopulate_email', $output );
$this->assertStringContainsString( 'jetpack_create_missing_account', $output );
$this->assertStringContainsString( 'text/javascript', $output );
$this->assertStringContainsString( 'getElementById', $output );
$this->assertStringContainsString( 'administrator', $output );

// Clean up
unset( $_GET['jetpack_protected_owner_email'] );
unset( $_GET['jetpack_create_missing_account'] );
}

/**
* Test prepopulate_user_form outputs nothing when no email is available.
*/
public function test_prepopulate_user_form_without_email() {
// Capture output
ob_start();
$this->handler->prepopulate_user_form();
$output = ob_get_clean();

// Should be empty
$this->assertEmpty( $output );
}

/**
* Test disable_wpcom_invite_for_protected_owner filter with protected owner context.
*/
public function test_disable_wpcom_invite_for_protected_owner_with_email() {
$test_email = 'test@example.com';

// Set up URL parameters to create protected owner context
$_GET['jetpack_protected_owner_email'] = $test_email;
$_GET['jetpack_create_missing_account'] = '1';

// Test that the filter disables invitations
$result = $this->handler->disable_wpcom_invite_for_protected_owner( true );
$this->assertFalse( $result );

// Test that it also works when the original value is false
$result = $this->handler->disable_wpcom_invite_for_protected_owner( false );
$this->assertFalse( $result );

// Clean up
unset( $_GET['jetpack_protected_owner_email'] );
unset( $_GET['jetpack_create_missing_account'] );
}

/**
* Test disable_wpcom_invite_for_protected_owner filter without protected owner context.
*/
public function test_disable_wpcom_invite_for_protected_owner_without_email() {
// Test that without protected owner context, original value is preserved
$result = $this->handler->disable_wpcom_invite_for_protected_owner( true );
$this->assertTrue( $result );

$result = $this->handler->disable_wpcom_invite_for_protected_owner( false );
$this->assertFalse( $result );
}
}
21 changes: 21 additions & 0 deletions projects/plugins/wpcomsh/tests/lib/mocks/class-jetpack-options.php
Original file line number Diff line number Diff line change
@@ -37,5 +37,26 @@ public static function get_option( $option_name, $default = false ) { // phpcs:i
public static function get_option_and_ensure_autoload( $name, $default ) {
return self::get_option( $name, $default );
}

/**
* Update option.
*
* @param string $option_name Option name.
* @param mixed $value Option value.
* @return bool True if the option was updated, false otherwise.
*/
public static function update_option( $option_name, $value ) {
return update_option( $option_name, $value );
}

/**
* Delete option.
*
* @param string $option_name Option name.
* @return bool True if the option was deleted, false otherwise.
*/
public static function delete_option( $option_name ) {
return delete_option( $option_name );
}
}
}
3 changes: 3 additions & 0 deletions projects/plugins/wpcomsh/wpcomsh.php
Original file line number Diff line number Diff line change
@@ -26,6 +26,9 @@
require_once __DIR__ . '/i18n.php';
require_once __DIR__ . '/lib/require-lib.php';

// Protected Owner functionality for Jetpack Connection
require_once __DIR__ . '/connection/protected-owner-handlers.php';

require_once __DIR__ . '/plugin-hotfixes.php';

require_once __DIR__ . '/footer-credit/footer-credit.php';