Skip to content
Merged
Show file tree
Hide file tree
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
81 changes: 67 additions & 14 deletions inc/checkout/class-checkout.php
Original file line number Diff line number Diff line change
Expand Up @@ -899,20 +899,7 @@ public function process_order() {
* let's check if the user is logged in,
* and if not, let's do that.
*/
if ( ! is_user_logged_in()) {
wp_clear_auth_cookie();

$user_credentials = array(
'user_login' => $this->customer->get_username(),
'user_password' => $this->request_or_session('password'),
);

// Remove the pending payment check action so the customer is not prompted to pay for the payment when they are already on the checkout page.
remove_action('wp_login', array(Payment_Manager::get_instance(), 'check_pending_payments'), 10);

// Sign in the user as if they used the login form.
wp_signon($user_credentials, is_ssl());
}
$this->login_customer_after_checkout();

/*
* Action time.
Expand Down Expand Up @@ -2220,6 +2207,72 @@ protected function form_has_auto_generate_password(): bool {
return false;
}

/**
* Logs the customer in immediately after a successful checkout submission.
*
* When a password was collected by the form (standard flow) we call
* wp_signon() so WordPress performs its normal credential round-trip.
* When no password was collected — e.g. the form uses
* auto_generate_password or has no password field at all (the simple
* preset with email-only) — wp_signon() would receive an empty credential
* and silently fail, leaving the user logged-out and triggering the
* "You need to be logged in" error on the finish-checkout page. In that
* case we set the auth cookie directly, since the customer record was
* just created in this very request and the credential is not available.
*
* @since 2.6.0
* @return void
*/
protected function login_customer_after_checkout() {

if (is_user_logged_in()) {
return;
}

wp_clear_auth_cookie();

// Remove the pending payment check action so the customer is not
// prompted to pay for the payment when they are already on the
// checkout page.
remove_action('wp_login', array(Payment_Manager::get_instance(), 'check_pending_payments'), 10);

$password = $this->request_or_session('password');

if ($password) {
$user_credentials = array(
'user_login' => $this->customer->get_username(),
'user_password' => $password,
);

// Sign in the user as if they used the login form.
wp_signon($user_credentials, is_ssl());

return;
}

/*
* No password was collected (e.g. the form uses auto_generate_password
* or has no password field at all — the simple preset). We just
* created this user, so log them in directly via the auth cookie
* rather than a credential round-trip that would fail with an empty
* password and silently leave the user logged out.
*/
$user_id = $this->customer->get_user_id();

if ( ! $user_id) {
return;
}

$user = get_user_by('ID', $user_id);

if ( ! $user) {
return;
}

wp_set_auth_cookie($user_id, false, is_ssl());
do_action('wp_login', $user->user_login, $user);
}

/**
* Converts the PHP validation rules into a JS-friendly structure.
*
Expand Down
241 changes: 241 additions & 0 deletions tests/WP_Ultimo/Checkout/Checkout_Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -4752,6 +4752,247 @@ public function test_get_validation_rules_non_template_required_field_maps_to_it
$this->assertStringNotContainsString('min:1', $rules['site_title']);
}

// -------------------------------------------------------------------------
// login_customer_after_checkout
// -------------------------------------------------------------------------

/**
* Helper: get the login_customer_after_checkout method via reflection.
*/
private function get_login_method(\ReflectionClass $reflection): \ReflectionMethod {
$method = $reflection->getMethod('login_customer_after_checkout');
if (PHP_VERSION_ID < 80100) {
$method->setAccessible(true);
}
return $method;
}

/**
* Helper: inject a customer object into the checkout singleton.
*/
private function inject_customer(Checkout $checkout, \ReflectionClass $reflection, $customer): void {
$prop = $reflection->getProperty('customer');
if (PHP_VERSION_ID < 80100) {
$prop->setAccessible(true);
}
$prop->setValue($checkout, $customer);
}

/**
* Test login_customer_after_checkout is a no-op when already logged in.
*
* If the user is already authenticated, wp_login should never fire.
*/
public function test_login_customer_after_checkout_noop_when_logged_in(): void {

$user_id = self::factory()->user->create(['role' => 'subscriber']);
wp_set_current_user($user_id);

$checkout = Checkout::get_instance();
$reflection = new \ReflectionClass($checkout);
$method = $this->get_login_method($reflection);

$login_fired = false;
add_action('wp_login', function() use (&$login_fired) {
$login_fired = true;
});

$method->invoke($checkout);

remove_all_actions('wp_login');

$this->assertFalse($login_fired, 'wp_login must not fire when user is already logged in');

wp_set_current_user(0);
require_once ABSPATH . 'wp-admin/includes/user.php';
wp_delete_user($user_id);
}

/**
* Test login_customer_after_checkout fires wp_login via auth cookie
* when no password is available (simple-preset / auto_generate_password).
*
* This is the bug scenario: email-only checkout form → no password in
* session or request → wp_signon() would silently fail → user ends up
* logged out on the finish-checkout page.
*/
public function test_login_customer_after_checkout_no_password_fires_wp_login(): void {

$unique = uniqid('nopw_', true);
$user_id = self::factory()->user->create([
'user_login' => $unique,
'user_pass' => wp_generate_password(),
'user_email' => $unique . '@example.com',
]);

wp_set_current_user(0);
wp_clear_auth_cookie();

$customer = wu_create_customer([
'user_id' => $user_id,
'username' => $unique,
'email' => $unique . '@example.com',
]);

if (is_wp_error($customer)) {
require_once ABSPATH . 'wp-admin/includes/user.php';
wp_delete_user($user_id);
$this->markTestSkipped('Customer creation failed: ' . $customer->get_error_message());
}

$checkout = Checkout::get_instance();
$reflection = new \ReflectionClass($checkout);

$this->inject_customer($checkout, $reflection, $customer);
$this->ensure_session($checkout);

// No password in request or session.
unset($_REQUEST['password']);

$login_fired = false;
$login_user_arg = null;
add_action('wp_login', function($user_login, $user) use (&$login_fired, &$login_user_arg) {
$login_fired = true;
$login_user_arg = $user;
}, 10, 2);

$method = $this->get_login_method($reflection);
$method->invoke($checkout);

remove_all_actions('wp_login');

$this->assertTrue($login_fired, 'wp_login must fire when auto-logging in via auth cookie (no password path)');
$this->assertInstanceOf(\WP_User::class, $login_user_arg);
$this->assertEquals($user_id, $login_user_arg->ID);

// Cleanup.
wp_set_current_user(0);
$customer->delete();
require_once ABSPATH . 'wp-admin/includes/user.php';
wp_delete_user($user_id);
}

/**
* Test login_customer_after_checkout uses wp_signon when a password is provided.
*
* wp_signon() internally fires wp_login on success.
*/
public function test_login_customer_after_checkout_with_password_fires_wp_login(): void {

$unique = uniqid('withpw_', true);
$password = 'TestP@ssw0rd!';
$user_id = self::factory()->user->create([
'user_login' => $unique,
'user_pass' => $password,
'user_email' => $unique . '@example.com',
]);

wp_set_current_user(0);
wp_clear_auth_cookie();

$customer = wu_create_customer([
'user_id' => $user_id,
'username' => $unique,
'email' => $unique . '@example.com',
]);

if (is_wp_error($customer)) {
require_once ABSPATH . 'wp-admin/includes/user.php';
wp_delete_user($user_id);
$this->markTestSkipped('Customer creation failed: ' . $customer->get_error_message());
}

$checkout = Checkout::get_instance();
$reflection = new \ReflectionClass($checkout);

$this->inject_customer($checkout, $reflection, $customer);
$this->ensure_session($checkout);

$_REQUEST['password'] = $password;

$login_fired = false;
add_action('wp_login', function() use (&$login_fired) {
$login_fired = true;
});

$method = $this->get_login_method($reflection);
$method->invoke($checkout);

remove_all_actions('wp_login');
unset($_REQUEST['password']);

$this->assertTrue($login_fired, 'wp_login must fire when logging in via wp_signon (password path)');

// Cleanup.
wp_set_current_user(0);
$customer->delete();
require_once ABSPATH . 'wp-admin/includes/user.php';
wp_delete_user($user_id);
}

/**
* Test login_customer_after_checkout handles a customer with user_id = 0 gracefully.
*
* If get_user_id() returns 0 (no linked WP user), the method must not
* throw and must not fire wp_login. We simulate this by setting the
* customer's user_id to 0 via its public setter rather than deleting a
* real user (WP caches users in-process making deletion unreliable in
* a unit-test context).
*/
public function test_login_customer_after_checkout_missing_wp_user_is_safe(): void {

wp_set_current_user(0);
wp_clear_auth_cookie();

// Build a real customer so inject_customer has a valid object to work
// with, then point its user_id at 0 so get_user_by('ID', 0) returns false.
$unique = uniqid('orphan_', true);
$user_id = self::factory()->user->create([
'user_login' => $unique,
'user_pass' => wp_generate_password(),
'user_email' => $unique . '@example.com',
]);
$customer = wu_create_customer([
'user_id' => $user_id,
'username' => $unique,
'email' => $unique . '@example.com',
]);

if (is_wp_error($customer)) {
require_once ABSPATH . 'wp-admin/includes/user.php';
wp_delete_user($user_id);
$this->markTestSkipped('Customer creation failed: ' . $customer->get_error_message());
}

// Point the customer at user 0 (guaranteed to not exist).
$customer->set_user_id(0);

$checkout = Checkout::get_instance();
$reflection = new \ReflectionClass($checkout);

$this->inject_customer($checkout, $reflection, $customer);
$this->ensure_session($checkout);
unset($_REQUEST['password']);

$login_fired = false;
add_action('wp_login', function() use (&$login_fired) {
$login_fired = true;
});

// Must not throw.
$method = $this->get_login_method($reflection);
$method->invoke($checkout);

remove_all_actions('wp_login');

$this->assertFalse($login_fired, 'wp_login must not fire when the customer has no linked WP user');

// Cleanup.
$customer->delete();
require_once ABSPATH . 'wp-admin/includes/user.php';
wp_delete_user($user_id);
}

// -------------------------------------------------------------------------
// Teardown
// -------------------------------------------------------------------------
Expand Down
Loading