Files
projectsend/tests/Feature/Identity/ConnectedAccountsTest.php
T
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

218 lines
8.3 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLog;
use App\Modules\Identity\AuthSource;
use App\Modules\Identity\Social\SocialAccount;
use App\Modules\Identity\Social\SocialGateway;
use App\Modules\Identity\Social\SocialIdentity;
use App\Modules\Identity\Social\SocialProvider;
use App\Modules\Identity\Social\SocialSettings;
use Illuminate\Support\Facades\Password;
use Illuminate\Testing\TestResponse;
use Tests\Support\FakeSocialGateway;
beforeEach(function () {
$this->staff = User::factory()->create();
$settings = SocialSettings::for(SocialProvider::Google);
$settings->forceFill([
'provider' => 'google',
'enabled' => true,
'client_id' => 'id',
'client_secret' => 'secret',
])->save();
});
function connect(User $user, SocialIdentity $identity): TestResponse
{
test()->swap(SocialGateway::class, new FakeSocialGateway($identity));
test()->actingAs($user)->post(route('connected-accounts.connect', ['provider' => $identity->provider->value]));
return test()->actingAs($user)->get(route('social.callback', ['provider' => $identity->provider->value]));
}
function linkRow(User $user, string $subject, SocialProvider $provider = SocialProvider::Google): SocialAccount
{
return SocialAccount::query()->create([
'user_id' => $user->id,
'provider' => $provider->value,
'provider_user_id' => $subject,
'email' => $user->email,
]);
}
/*
|--------------------------------------------------------------------------
| Connecting
|--------------------------------------------------------------------------
|
| Connecting from here is the safe way to use a provider that cannot
| verify an address: the account is established by the session, so the
| address is never taken on trust at all.
|
*/
test('a signed-in person connects a provider, unverified address and all', function () {
$identity = new SocialIdentity(SocialProvider::Google, 'sub-1', 'anything@elsewhere.test', false, 'Someone');
connect($this->staff, $identity)->assertRedirect(route('connected-accounts.edit'));
expect(SocialAccount::query()->where('user_id', $this->staff->id)->count())->toBe(1)
->and(ActivityLog::query()->where('action', Action::SocialAccountLinked)->exists())->toBeTrue();
});
test('a provider already connected to somebody else is refused', function () {
$other = User::factory()->client()->create();
linkRow($other, 'sub-1');
connect($this->staff, new SocialIdentity(SocialProvider::Google, 'sub-1', 'x@example.test', true, 'X'))
->assertRedirect(route('connected-accounts.edit'));
expect(SocialAccount::query()->where('user_id', $this->staff->id)->exists())->toBeFalse()
->and(SocialAccount::query()->where('user_id', $other->id)->exists())->toBeTrue();
});
test('reconnecting a different account at the same provider replaces the link', function () {
connect($this->staff, new SocialIdentity(SocialProvider::Google, 'sub-1', 'one@example.test', true, 'One'));
connect($this->staff, new SocialIdentity(SocialProvider::Google, 'sub-2', 'two@example.test', true, 'Two'));
$links = SocialAccount::query()->where('user_id', $this->staff->id)->get();
expect($links)->toHaveCount(1)
->and($links->first()->provider_user_id)->toBe('sub-2');
});
/*
|--------------------------------------------------------------------------
| Starting the exchange
|--------------------------------------------------------------------------
|
| Connecting starts as an Inertia XHR, and an XHR cannot follow a 302 to
| the provider: the browser refuses the cross-origin hop and nobody goes
| anywhere. Inertia's 409 + X-Inertia-Location pair is what turns the
| same answer into a real top-level navigation.
|
*/
test('connecting from the settings screen navigates the browser, not the XHR', function () {
test()->swap(SocialGateway::class, new FakeSocialGateway);
$this->actingAs($this->staff)
->post(route('connected-accounts.connect', ['provider' => 'google']), [], ['X-Inertia' => 'true'])
->assertStatus(409)
->assertHeader('X-Inertia-Location', 'https://provider.test/authorize');
});
test('a plain request is still given the provider redirect itself', function () {
test()->swap(SocialGateway::class, new FakeSocialGateway);
$this->actingAs($this->staff)
->post(route('connected-accounts.connect', ['provider' => 'google']))
->assertRedirect('https://provider.test/authorize');
});
/*
|--------------------------------------------------------------------------
| Disconnecting
|--------------------------------------------------------------------------
*/
test('a local account can disconnect its only provider', function () {
linkRow($this->staff, 'sub-1');
$this->actingAs($this->staff)
->delete(route('connected-accounts.destroy', ['provider' => 'google']))
->assertSessionHasNoErrors();
expect(SocialAccount::query()->count())->toBe(0)
->and(ActivityLog::query()->where('action', Action::SocialAccountUnlinked)->exists())->toBeTrue();
});
// The mirror image of AccountConversion::requiresNewPassword(): an account
// created by a provider holds a random password nobody has ever seen, so
// the provider is the only way in.
test('an account created by a provider cannot remove its last connection', function () {
$client = User::factory()->client()->create();
$client->forceFill(['auth_source' => AuthSource::Social])->save();
linkRow($client, 'sub-1');
$this->actingAs($client)
->delete(route('connected-accounts.destroy', ['provider' => 'google']))
->assertSessionHasErrors('provider');
expect(SocialAccount::query()->count())->toBe(1);
});
// And the instruction inside that refusal, followed. AuthSource says a
// social account "may later set a real password"; a reset by emailed token
// is where somebody does, and nothing else in the application records it.
test('a provider account that resets its password can then disconnect', function () {
$client = User::factory()->client()->create();
$client->forceFill(['auth_source' => AuthSource::Social])->save();
linkRow($client, 'sub-1');
$this->post(route('password.store'), [
'token' => Password::createToken($client),
'email' => $client->email,
'password' => 'a-password-of-her-own',
'password_confirmation' => 'a-password-of-her-own',
])->assertSessionHasNoErrors();
expect($client->fresh()->auth_source)->toBe(AuthSource::Local);
$this->actingAs($client->fresh())
->delete(route('connected-accounts.destroy', ['provider' => 'google']))
->assertSessionHasNoErrors();
expect(SocialAccount::query()->count())->toBe(0);
});
test('it can remove one of two connections', function () {
$client = User::factory()->client()->create();
$client->forceFill(['auth_source' => AuthSource::Social])->save();
linkRow($client, 'sub-1');
linkRow($client, 'gh-1', SocialProvider::Github);
$this->actingAs($client)
->delete(route('connected-accounts.destroy', ['provider' => 'google']))
->assertSessionHasNoErrors();
expect(SocialAccount::query()->where('user_id', $client->id)->count())->toBe(1);
});
test('one account cannot disconnect another account link', function () {
$other = User::factory()->client()->create();
linkRow($other, 'sub-1');
$this->actingAs($this->staff)->delete(route('connected-accounts.destroy', ['provider' => 'google']));
expect(SocialAccount::query()->where('user_id', $other->id)->exists())->toBeTrue();
});
/*
|--------------------------------------------------------------------------
| The screen
|--------------------------------------------------------------------------
*/
test('the screen is reachable by clients as well as staff', function () {
$client = User::factory()->client()->create();
$this->actingAs($client)->get('/settings/connected-accounts')->assertOk();
$this->actingAs($this->staff)->get('/settings/connected-accounts')->assertOk();
});
test('a guest is sent to the login page', function () {
$this->get('/settings/connected-accounts')->assertRedirect(route('login'));
});
test('only usable providers are listed', function () {
$this->actingAs($this->staff)->get('/settings/connected-accounts')
->assertInertia(fn ($page) => $page->has('providers', 1)->where('providers.0.provider', 'google'));
});