create(array_merge(['auth_source' => AuthSource::Social], $overrides)); } test('a provider account cannot set its first password from the session alone', function () { $user = providerAccount(); $this->actingAs($user) ->from('/settings/password') ->put('/settings/password', [ 'password' => 'chosen-by-whoever-holds-the-session', 'password_confirmation' => 'chosen-by-whoever-holds-the-session', ]) ->assertSessionHasErrors('password'); $user->refresh(); expect(Hash::check('chosen-by-whoever-holds-the-session', $user->password))->toBeFalse() ->and($user->auth_source)->toBe(AuthSource::Social); }); test('a provider account asks for a link, and it goes to its own address', function () { Notification::fake(); $user = providerAccount(); $this->actingAs($user)->from('/settings/password')->post('/settings/password/link') ->assertSessionHasNoErrors() ->assertRedirect('/settings/password'); Notification::assertSentTo($user, ResetPasswordNotification::class); }); test('the link sets the first password, signed in or not, and makes the account local', function () { $user = providerAccount(); $token = Password::createToken($user); // Opened in the same browser the person is signed in with. $this->actingAs($user)->get("/reset-password/{$token}?email=".urlencode($user->email))->assertOk(); $this->actingAs($user)->post('/reset-password', [ 'token' => $token, 'email' => $user->email, 'password' => 'a-password-of-my-own', 'password_confirmation' => 'a-password-of-my-own', ])->assertSessionHasNoErrors(); $user->refresh(); expect(Hash::check('a-password-of-my-own', $user->password))->toBeTrue() ->and($user->auth_source)->toBe(AuthSource::Local); }); test('using the link signs out the session that asked for it', function () { $user = providerAccount(); $token = Password::createToken($user); $this->actingAs($user)->post('/reset-password', [ 'token' => $token, 'email' => $user->email, 'password' => 'a-password-of-my-own', 'password_confirmation' => 'a-password-of-my-own', ]); // Whoever held the session before has the password no longer: the // next thing it asks for sends it to sign in. forgetRequestState() so // that request reads the account afresh, as a real one would. forgetRequestState(); $this->post('/confirm-password', ['password' => 'a-password-of-my-own'])->assertRedirect(route('login')); $this->assertGuest(); }); test('the email to a provider account is about setting a first password, not a reset', function () { Notification::fake(); $user = providerAccount(); $this->actingAs($user)->post('/settings/password/link'); Notification::assertSentTo($user, ResetPasswordNotification::class, function (ResetPasswordNotification $notification) use ($user): bool { $mail = $notification->toMail($user); return $mail->subject === __('Set your password') && $mail->actionText === __('Set a password') && ! str_contains(implode(' ', $mail->introLines), 'reset'); }); }); test('an account with a password still gets the reset email', function () { Notification::fake(); $user = User::factory()->create(); $this->post('/forgot-password', ['email' => $user->email]); Notification::assertSentTo($user, ResetPasswordNotification::class, function (ResetPasswordNotification $notification) use ($user): bool { return $notification->toMail($user)->subject === __('Reset Password Notification'); }); }); test('an account that already has a password uses the form, not a link', function () { Notification::fake(); $user = User::factory()->create(); $this->actingAs($user)->post('/settings/password/link')->assertForbidden(); Notification::assertNothingSent(); }); test('a directory account is not sent a link either', function () { Notification::fake(); $this->actingAs(providerAccount(['auth_source' => AuthSource::Ldap]))->post('/settings/password/link')->assertForbidden(); Notification::assertNothingSent(); }); test('without a password, the last provider still cannot be removed', function () { $user = providerAccount(); SocialAccount::query()->create(['user_id' => $user->id, 'provider' => 'google', 'provider_user_id' => 'g-1']); $this->actingAs($user)->put('/settings/password', [ 'password' => 'chosen-by-whoever-holds-the-session', 'password_confirmation' => 'chosen-by-whoever-holds-the-session', ]); $this->actingAs($user->refresh())->delete('/settings/connected-accounts/google')->assertSessionHasErrors('provider'); expect(SocialAccount::query()->where('user_id', $user->id)->exists())->toBeTrue(); }); test('an ordinary account still has to prove the password it is replacing', function () { $user = User::factory()->create(); $this->actingAs($user) ->from('/settings/password') ->put('/settings/password', [ 'password' => 'a-password-of-my-own', 'password_confirmation' => 'a-password-of-my-own', ]) ->assertSessionHasErrors('current_password'); expect(Hash::check('a-password-of-my-own', $user->refresh()->password))->toBeFalse(); }); test('a directory account is refused rather than given a password that signs nothing in', function () { $user = providerAccount(['auth_source' => AuthSource::Ldap]); $this->actingAs($user) ->put('/settings/password', [ 'password' => 'a-password-of-my-own', 'password_confirmation' => 'a-password-of-my-own', ]) ->assertForbidden(); expect(Hash::check('a-password-of-my-own', $user->refresh()->password))->toBeFalse(); }); test('the screen says which of the two it is', function () { $this->actingAs(providerAccount())->get('/settings/password')->assertInertia( fn (AssertableInertia $page) => $page->where('has_local_password', false)->where('managed_elsewhere', false), ); $this->actingAs(User::factory()->create())->get('/settings/password')->assertInertia( fn (AssertableInertia $page) => $page->where('has_local_password', true), ); }); test('the confirm-password screen offers to set one instead of asking for it', function () { $this->actingAs(providerAccount())->get('/confirm-password')->assertInertia( fn (AssertableInertia $page) => $page->component('auth/confirm-password')->where('has_password', false), ); }); /* |-------------------------------------------------------------------------- | The lockout |-------------------------------------------------------------------------- */ test('compulsory two-factor leaves a provider account a way to get a password', function () { app(Settings::class)->set(Setting::TwoFactorEnforcement, 'staff'); $user = providerAccount(); // Enforcement holds everywhere else, as before. $this->actingAs($user)->get('/dashboard')->assertRedirect(route('two-factor.show')); // But not on the one screen that can end the loop. $this->actingAs($user)->get('/settings/password')->assertOk(); // The link is asked for from that screen, and opened past the // enforcement too: both are part of ending the loop. Notification::fake(); $this->actingAs($user)->post('/settings/password/link')->assertSessionHasNoErrors(); Notification::assertSentTo($user, ResetPasswordNotification::class); $token = Password::createToken($user); $this->actingAs($user)->get("/reset-password/{$token}?email=".urlencode($user->email))->assertOk(); $this->actingAs($user)->post('/reset-password', [ 'token' => $token, 'email' => $user->email, 'password' => 'a-password-of-my-own', 'password_confirmation' => 'a-password-of-my-own', ])->assertSessionHasNoErrors(); // Setting it ends every session holding the old password, the one that // asked for the link included, so the way on is to sign in again. auth()->logout(); forgetRequestState(); $this->post('/login', ['email' => $user->email, 'password' => 'a-password-of-my-own']); $this->assertAuthenticatedAs($user); // And with a password, the confirmation in front of enrolling is // answerable, so two-factor can actually be turned on. $this->post('/confirm-password', ['password' => 'a-password-of-my-own'])->assertSessionHasNoErrors(); $this->post('/settings/two-factor')->assertSessionHasNoErrors(); expect($user->refresh()->two_factor_secret)->not->toBeNull(); });