mirror of
https://github.com/projectsend/projectsend.git
synced 2026-09-16 16:45:07 +00:00
6339ae1514
The screen leaked account existence a second way, through the write, and this one is older than last night's: it is the scaffolding. Laravel answers a failed reset with passwords.user for an address it cannot find and passwords.token for a real one whose token is dead, and the controller surfaced __($status) straight through. Two sentences, one difference, and the difference is whether the account is here. passwords.throttled is the third and the sharpest. The broker throttles per user, so an address nobody holds can never be throttled — being told to wait is being told the account exists. All of them collapse to one sentence now. Nothing is lost: the action is the same in every case, and /forgot-password one step earlier already refuses to say whether an address has an account. Keeping three messages was only ever more precise about a thing we had decided not to say. Found because the portal session went looking for the GET oracle I had just fixed, found theirs, and also found a POST variant I had not thought to check. I had it too. The test asserts the two refusals are identical rather than naming the sentence, so it survives the wording changing.
124 lines
4.9 KiB
PHP
124 lines
4.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
use App\Models\User;
|
|
use Illuminate\Support\Facades\Password;
|
|
use Inertia\Testing\AssertableInertia;
|
|
|
|
/**
|
|
* An expired reset link should say so before asking for the work, not
|
|
* after it.
|
|
*
|
|
* The scaffolding renders the form without looking at the token, so
|
|
* somebody opening a link an hour late typed a password, typed it again to
|
|
* confirm, and was then told "this password reset token is invalid" — a
|
|
* word nobody outside the code knows, at the end rather than the start.
|
|
*
|
|
* store() still validates and is still the rule. This is only the screen
|
|
* being honest a minute earlier.
|
|
*/
|
|
beforeEach(function () {
|
|
$this->user = User::factory()->create(['email' => 'owner@example.com']);
|
|
});
|
|
|
|
test('a live link still shows the form', function () {
|
|
$token = Password::broker()->createToken($this->user);
|
|
|
|
$this->get("/reset-password/{$token}?email=owner@example.com")
|
|
->assertOk()
|
|
->assertInertia(fn (AssertableInertia $page) => $page
|
|
->component('auth/reset-password')
|
|
->where('expired', false));
|
|
});
|
|
|
|
test('a spent link says so instead of asking for a password', function () {
|
|
$this->get('/reset-password/not-a-real-token?email=owner@example.com')
|
|
->assertOk()
|
|
->assertInertia(fn (AssertableInertia $page) => $page->where('expired', true));
|
|
});
|
|
|
|
test('a link whose token has been used is spent', function () {
|
|
$token = Password::broker()->createToken($this->user);
|
|
Password::broker()->deleteToken($this->user);
|
|
|
|
$this->get("/reset-password/{$token}?email=owner@example.com")
|
|
->assertOk()
|
|
->assertInertia(fn (AssertableInertia $page) => $page->where('expired', true));
|
|
});
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------
|
|
| It must not answer whether an account exists
|
|
|--------------------------------------------------------------------------
|
|
|
|
|
| /forgot-password deliberately says "a link will be sent if the account
|
|
| exists". This screen must not undo that, and the first version of it did:
|
|
| a real address answered "expired" and an unknown one drew the form, so
|
|
| the difference between the two answers was the account.
|
|
*/
|
|
|
|
test('an address nobody has gets the same answer as one that exists', function () {
|
|
// The oracle, pinned. Not "both are false" or "both are true" — both
|
|
// are *the same*, which is the property, and it survives somebody
|
|
// later changing which answer that is.
|
|
User::factory()->create(['email' => 'known@example.com']);
|
|
|
|
$expiredFor = function (string $email): bool {
|
|
$seen = null;
|
|
test()->get("/reset-password/not-a-real-token?email={$email}")->assertOk()->assertInertia(
|
|
function (AssertableInertia $page) use (&$seen) {
|
|
$seen = $page->toArray()['props']['expired'];
|
|
},
|
|
);
|
|
|
|
return (bool) $seen;
|
|
};
|
|
|
|
expect($expiredFor('known@example.com'))->toBe($expiredFor('nobody@example.com'));
|
|
});
|
|
|
|
test('the write refuses both the same way', function () {
|
|
// The GET is not the only way to ask. Laravel answers a failed reset
|
|
// with passwords.user for an address it cannot find and passwords.token
|
|
// for a real one whose token is dead — two different sentences, which
|
|
// is the same oracle through the POST. This one predates the screen
|
|
// above; it is the scaffolding, shipped in every release.
|
|
User::factory()->create(['email' => 'known@example.com']);
|
|
|
|
$refusalFor = function (string $email): string {
|
|
return test()->from('/reset-password/not-a-real-token')
|
|
->post('/reset-password', [
|
|
'token' => 'not-a-real-token',
|
|
'email' => $email,
|
|
'password' => 'a-brand-new-password',
|
|
'password_confirmation' => 'a-brand-new-password',
|
|
])
|
|
->assertSessionHasErrors('email')
|
|
->getSession()->get('errors')->first('email');
|
|
};
|
|
|
|
expect($refusalFor('known@example.com'))->toBe($refusalFor('nobody@example.com'));
|
|
});
|
|
|
|
test('a missing address reads as expired rather than as a form', function () {
|
|
// Same rule seen from the other side. The form needs an address to
|
|
// post, so drawing it here would ask for a password it cannot use.
|
|
$this->get('/reset-password/not-a-real-token')
|
|
->assertOk()
|
|
->assertInertia(fn (AssertableInertia $page) => $page->where('expired', true));
|
|
});
|
|
|
|
test('the real check still happens on the write', function () {
|
|
// The screen is a courtesy; store() is the rule. A spent token is
|
|
// refused there whatever the page decided to draw.
|
|
$this->post('/reset-password', [
|
|
'token' => 'not-a-real-token',
|
|
'email' => 'owner@example.com',
|
|
'password' => 'a-brand-new-password',
|
|
'password_confirmation' => 'a-brand-new-password',
|
|
])->assertSessionHasErrors('email');
|
|
|
|
expect(Hash::check('a-brand-new-password', $this->user->fresh()->password))->toBeFalse();
|
|
});
|