Files
denkfabrik-li 27c289a4d6 Let a password reset know where the account's credentials live
Two accounts reach the same reset with opposite needs, and it treated
both as "write a hash and hope".

A provider-created account is told, on the Connected accounts screen, to
"set a password first, then disconnect Google" -- and doing it changed
nothing, because nothing ever set auth_source back to Local.
AccountConversion is the only writer, and that is an administrator. So the
screen went on asking for something that had already been done, and the
person could not release their last provider without help.

AuthSource states the rule that closes this: `social` means the account
came into existence without anybody choosing a password, and, in as many
words, "a social account may later set a real password". A reset by
emailed token is where somebody does. The screen's has_local_password prop
is literally auth_source === Local, so the write is what completes the
sentence it prints.

A directory account is the opposite case and gets the opposite answer.
isDirectoryAccount() means the local hash is not consulted at all, so the
reset reported success and left the person with a password that cannot
sign them in -- including when the directory it points at is gone, which
is exactly when somebody reaches for a reset. It is refused now, with the
reason, and nothing about the account moves: taking one off its directory
is an administrator's decision through AccountConversion, not a side
effect of a reset.

The refusal sits where the token has already been validated, not where the
link is asked for. That endpoint answers "A reset link will be sent if the
account exists" to everybody on purpose, and refusing there would tell a
stranger both that an address is an account and how it signs in. Throwing
before the write also leaves the token unspent, since PasswordBroker
deletes it after the callback returns.
2026-08-28 14:01:24 +02:00

168 lines
5.6 KiB
PHP

<?php
namespace Tests\Feature\Auth;
use App\Models\User;
use App\Modules\Identity\AuthSource;
use App\Modules\Identity\Notifications\ResetPasswordNotification;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Password;
use Tests\TestCase;
class PasswordResetTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
// The app redirects everything to /setup until a staff user exists.
User::factory()->create();
}
public function test_reset_password_link_screen_can_be_rendered()
{
$response = $this->get('/forgot-password');
$response->assertStatus(200);
}
public function test_reset_password_link_can_be_requested()
{
Notification::fake();
$user = User::factory()->create();
$this->post('/forgot-password', ['email' => $user->email]);
Notification::assertSentTo($user, ResetPasswordNotification::class);
}
public function test_reset_password_screen_can_be_rendered()
{
Notification::fake();
$user = User::factory()->create();
$this->post('/forgot-password', ['email' => $user->email]);
Notification::assertSentTo($user, ResetPasswordNotification::class, function ($notification) {
$response = $this->get('/reset-password/'.$notification->token);
$response->assertStatus(200);
return true;
});
}
public function test_password_can_be_reset_with_valid_token()
{
Notification::fake();
$user = User::factory()->create();
$this->post('/forgot-password', ['email' => $user->email]);
Notification::assertSentTo($user, ResetPasswordNotification::class, function ($notification) use ($user) {
$response = $this->post('/reset-password', [
'token' => $notification->token,
'email' => $user->email,
'password' => 'new-password-1234',
'password_confirmation' => 'new-password-1234',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect(route('login'));
return true;
});
}
/**
* That this endpoint enforces the policy at all — the shipped default
* being a 12-character minimum.
*
* This assertion used to be described as covering the policy itself,
* on the grounds that Password::defaults() is central and every field
* leans on it. That stopped being true when the minimum became
* configurable: PasswordPolicy now decides it, and whether a *changed*
* minimum reaches each surface is proven in
* tests/Feature/Identity/PasswordPolicyTest.php.
*/
public function test_a_password_below_the_minimum_length_is_rejected()
{
Notification::fake();
$user = User::factory()->create();
$this->post('/forgot-password', ['email' => $user->email]);
Notification::assertSentTo($user, ResetPasswordNotification::class, function ($notification) use ($user) {
$this->post('/reset-password', [
'token' => $notification->token,
'email' => $user->email,
'password' => 'short-11ch',
'password_confirmation' => 'short-11ch',
])->assertSessionHasErrors('password');
return true;
});
}
/**
* The reset used to report success for an account whose password does
* not live here: isDirectoryAccount() means the local hash is never
* consulted, so the password it wrote could not sign anybody in — and
* nothing said so. Nothing about the account moves either, the source
* included: taking an account off its directory is an administrator's
* decision, not a side effect of a reset.
*/
public function test_a_directory_account_is_told_where_its_password_lives()
{
$client = User::factory()->client()->create();
$client->forceFill([
'auth_source' => AuthSource::Ldap,
'ldap_dn' => 'cn=dana,dc=test',
])->save();
$before = $client->password;
$this->post('/reset-password', [
'token' => Password::createToken($client),
'email' => $client->email,
'password' => 'a-password-of-her-own',
'password_confirmation' => 'a-password-of-her-own',
])->assertSessionHasErrors('email');
$client->refresh();
$this->assertSame($before, $client->password);
$this->assertSame(AuthSource::Ldap, $client->auth_source);
$this->assertSame('cn=dana,dc=test', $client->ldap_dn);
}
/**
* The half that must not be refused, and the reason the check is
* isDirectoryAccount() rather than a comparison against auth_source:
* LDAP is client-only, enforced on the account, so a staff row is
* verified against the local hash whatever its source happens to say.
*/
public function test_a_staff_account_resets_whatever_its_source_says()
{
$staff = User::factory()->create();
$staff->forceFill(['auth_source' => AuthSource::Ldap])->save();
$this->post('/reset-password', [
'token' => Password::createToken($staff),
'email' => $staff->email,
'password' => 'a-password-of-his-own',
'password_confirmation' => 'a-password-of-his-own',
])->assertSessionHasNoErrors();
$this->assertTrue(Hash::check('a-password-of-his-own', $staff->fresh()->password));
}
}