Files
projectsend/tests/Feature/Auth/SocialLoginTest.php
T
denkfabrik-li 9ddd39c41d Refuse to provision over a deleted account's address instead of crashing
The unique index on `email` spans soft-deleted rows -- AvailableEmailRule
is built on exactly that, so a deleted account keeps its address until
erasure removes the row. The registration form learns this from
validation. The machine paths have no form: a directory or an identity
provider hands over an address and provision() inserts it.

Measured on main, a client deleted last week signing in through a
provider that may auto-provision:

  GET /auth/google/callback → 500   (QueryException, unique constraint)

Same shape through LDAP at POST /login. Nothing is created, nothing is
signed in, and what the person meets is a server error.

Both provisioners now ask ClientProvisioning::addressIsFree() first and
refuse. The social flow already has a refusal for an identity it cannot
provision -- "There is no account here for that address." -- which is also
all a stranger should learn: whether an address was once an account here
is not the provider's to publish. The LDAP flow falls through to the
ordinary failed sign-in.

Deliberately not resurrecting the deleted account. Restoring one because
a directory says the address exists is a decision for a person, not a
side effect of somebody logging in.

Two tests, one per path: the sign-in is refused, nothing is created, and
the trashed row is still trashed. Both go red without the fix.
2026-08-28 06:40:50 +02:00

489 lines
18 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLog;
use App\Modules\Groups\Models\Group;
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 App\Modules\Identity\TwoFactor\TwoFactorService;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Illuminate\Testing\TestResponse;
use Inertia\Testing\AssertableInertia;
use Tests\Support\FakeSocialGateway;
beforeEach(function () {
// Every HTTP test needs a staff account or EnsureSetupIsComplete
// redirects the whole app to /setup.
User::factory()->create();
});
function socialSettings(
SocialProvider $provider = SocialProvider::Google,
bool $enabled = true,
bool $requireVerified = true,
bool $autoProvision = false,
bool $autoApprove = false,
?string $allowedDomains = null,
): SocialSettings {
$settings = SocialSettings::for($provider);
$settings->forceFill([
'provider' => $provider->value,
'enabled' => $enabled,
'client_id' => 'client-id',
'client_secret' => 'client-secret',
'tenant_id' => $provider->needsTenantId() ? 'tenant-abc' : null,
'issuer_url' => $provider->needsIssuerUrl() ? 'https://idp.example.test' : null,
'require_verified_email' => $requireVerified,
'allowed_domains' => $allowedDomains,
'auto_provision' => $autoProvision,
'auto_approve' => $autoApprove,
])->save();
return $settings;
}
function identity(
string $email = 'someone@example.test',
bool $verified = true,
string $subject = 'subject-1',
SocialProvider $provider = SocialProvider::Google,
?string $name = 'Someone Else',
): SocialIdentity {
return new SocialIdentity($provider, $subject, $email, $verified, $name);
}
function fakeProvider(?SocialIdentity $identity): FakeSocialGateway
{
$fake = new FakeSocialGateway($identity);
test()->swap(SocialGateway::class, $fake);
return $fake;
}
/**
* Walk the whole redirect-and-callback round trip, so the session intent
* the callback requires is written the way a real sign-in writes it.
*/
function signInWith(SocialProvider $provider = SocialProvider::Google): TestResponse
{
test()->get(route('social.redirect', ['provider' => $provider->value]));
return test()->get(route('social.callback', ['provider' => $provider->value]));
}
/*
|--------------------------------------------------------------------------
| The takeover v1 shipped
|--------------------------------------------------------------------------
|
| v1 matched whatever address the provider returned against `email` OR
| `user`, verified or not, and signed you in as whoever held it. These are
| the tests that say it cannot happen here.
|
*/
test('an unverified address cannot reach an administrator account', function () {
socialSettings();
$admin = User::factory()->create(['email' => 'admin@example.test']);
fakeProvider(identity(email: 'admin@example.test', verified: false));
signInWith()->assertRedirect(route('login'));
expect(auth()->check())->toBeFalse()
->and(SocialAccount::query()->count())->toBe(0)
->and($admin->fresh()->auth_source)->toBe(AuthSource::Local);
});
test('an unverified address cannot reach a client account either', function () {
socialSettings();
User::factory()->client()->create(['email' => 'client@example.test']);
fakeProvider(identity(email: 'client@example.test', verified: false));
signInWith()->assertRedirect(route('login'));
expect(auth()->check())->toBeFalse();
});
test('a verified address links to the account holding it and signs in', function () {
socialSettings();
$client = User::factory()->client()->create(['email' => 'client@example.test']);
fakeProvider(identity(email: 'client@example.test', verified: true));
signInWith()->assertRedirect(route('dashboard'));
expect(auth()->id())->toBe($client->id)
->and(SocialAccount::query()->where('user_id', $client->id)->exists())->toBeTrue();
});
// The escape hatch for a directory that omits the claim. Opt-in, and it
// is the administrator's explicit decision.
test('turning the verified requirement off lets an unverified address link', function () {
socialSettings(requireVerified: false);
$client = User::factory()->client()->create(['email' => 'client@example.test']);
fakeProvider(identity(email: 'client@example.test', verified: false));
signInWith()->assertRedirect(route('dashboard'));
expect(auth()->id())->toBe($client->id);
});
/*
|--------------------------------------------------------------------------
| The subject is the identity, not the address
|--------------------------------------------------------------------------
*/
test('an existing link wins even after the address changes at the provider', function () {
socialSettings();
$client = User::factory()->client()->create(['email' => 'old@example.test']);
SocialAccount::query()->create([
'user_id' => $client->id,
'provider' => SocialProvider::Google->value,
'provider_user_id' => 'subject-1',
'email' => 'old@example.test',
]);
fakeProvider(identity(email: 'brand-new@example.test', verified: true, subject: 'subject-1'));
signInWith()->assertRedirect(route('dashboard'));
expect(auth()->id())->toBe($client->id);
});
test('a different subject does not inherit another account link', function () {
socialSettings();
$victim = User::factory()->create(['email' => 'victim@example.test']);
SocialAccount::query()->create([
'user_id' => $victim->id,
'provider' => SocialProvider::Google->value,
'provider_user_id' => 'the-real-one',
'email' => 'victim@example.test',
]);
// Same address, different person at the provider, and unverified.
fakeProvider(identity(email: 'victim@example.test', verified: false, subject: 'an-impostor'));
signInWith()->assertRedirect(route('login'));
expect(auth()->check())->toBeFalse();
});
/*
|--------------------------------------------------------------------------
| Microsoft's tenant, and Facebook's silence
|--------------------------------------------------------------------------
*/
test('a Microsoft identity from another tenant is not treated as verified', function () {
$settings = socialSettings(SocialProvider::Microsoft);
User::factory()->create(['email' => 'admin@example.test']);
// What SocialIdentity::fromSocialite() computes when `tid` does not
// match: the address is present, and worth nothing.
fakeProvider(identity(
email: 'admin@example.test',
verified: false,
provider: SocialProvider::Microsoft,
));
test()->get(route('social.redirect', ['provider' => 'microsoft']));
test()->get(route('social.callback', ['provider' => 'microsoft']))->assertRedirect(route('login'));
expect(auth()->check())->toBeFalse()
->and($settings->fresh()->tenant_id)->toBe('tenant-abc');
});
test('Facebook can create a new account but never reach an existing one', function () {
socialSettings(SocialProvider::Facebook, autoProvision: true, autoApprove: true);
User::factory()->create(['email' => 'admin@example.test']);
// Facebook is always unverified — there is no claim to read.
fakeProvider(identity(email: 'admin@example.test', verified: false, provider: SocialProvider::Facebook));
test()->get(route('social.redirect', ['provider' => 'facebook']));
test()->get(route('social.callback', ['provider' => 'facebook']))->assertRedirect(route('login'));
expect(auth()->check())->toBeFalse();
// A brand-new address takes nothing over, so it may provision — but
// it waits for a human, whatever auto_approve says.
fakeProvider(identity(email: 'nobody@example.test', verified: false, subject: 'fb-1', provider: SocialProvider::Facebook));
test()->get(route('social.redirect', ['provider' => 'facebook']));
test()->get(route('social.callback', ['provider' => 'facebook']));
$created = User::query()->where('email', 'nobody@example.test')->first();
expect($created)->not->toBeNull()
->and($created->active)->toBeFalse()
->and($created->account_requested)->toBeTrue()
->and(auth()->check())->toBeFalse();
});
/*
|--------------------------------------------------------------------------
| Provisioning
|--------------------------------------------------------------------------
*/
test('an unknown verified address provisions a client, never staff', function () {
socialSettings(autoProvision: true, autoApprove: true);
fakeProvider(identity(email: 'new@example.test', verified: true));
signInWith()->assertRedirect(route('dashboard'));
$created = User::query()->where('email', 'new@example.test')->firstOrFail();
expect($created->isClient())->toBeTrue()
->and($created->auth_source)->toBe(AuthSource::Social)
->and($created->active)->toBeTrue()
->and(auth()->id())->toBe($created->id)
->and(ActivityLog::query()->where('action', Action::SocialClientProvisioned)->exists())->toBeTrue();
});
test('provisioning is refused when the provider may not create accounts', function () {
socialSettings(autoProvision: false);
fakeProvider(identity(email: 'new@example.test', verified: true));
signInWith()->assertRedirect(route('login'));
expect(User::query()->where('email', 'new@example.test')->exists())->toBeFalse();
});
test('an address belonging to a deleted account is refused, not crashed', function () {
// The unique index on email spans soft-deleted rows, so the insert used
// to raise a QueryException mid-callback. The refusal says no more than
// it says for any other address the provider cannot provision: whether
// one was once an account here is not the provider's to publish.
socialSettings(autoProvision: true, autoApprove: true);
fakeProvider(identity(email: 'gone@example.test', verified: true));
$gone = User::factory()->client()->create(['email' => 'gone@example.test']);
$gone->delete();
signInWith()->assertRedirect(route('login'));
$this->assertGuest();
expect(User::query()->where('email', 'gone@example.test')->exists())->toBeFalse()
->and(User::withTrashed()->where('email', 'gone@example.test')->sole()->trashed())->toBeTrue();
});
test('a provisioned account waits for approval when the provider says so', function () {
socialSettings(autoProvision: true, autoApprove: false);
fakeProvider(identity(email: 'new@example.test', verified: true));
signInWith()->assertRedirect(route('login'));
$created = User::query()->where('email', 'new@example.test')->firstOrFail();
expect($created->account_requested)->toBeTrue()
->and(auth()->check())->toBeFalse();
});
test('a provisioned account joins the configured auto group', function () {
$group = Group::query()->create(['name' => 'Provider folk']);
app(Settings::class)->set(Setting::ClientsAutoGroup, $group->id);
socialSettings(autoProvision: true, autoApprove: true);
fakeProvider(identity(email: 'new@example.test', verified: true));
signInWith();
$created = User::query()->where('email', 'new@example.test')->firstOrFail();
expect($group->members()->where('users.id', $created->id)->exists())->toBeTrue();
});
/*
|--------------------------------------------------------------------------
| Domain allow-list
|--------------------------------------------------------------------------
*/
test('an address outside the allow-list is refused, sign-in and provisioning alike', function () {
socialSettings(autoProvision: true, autoApprove: true, allowedDomains: 'acme.test, acme.co.test');
$client = User::factory()->client()->create(['email' => 'client@example.test']);
fakeProvider(identity(email: 'client@example.test', verified: true));
signInWith()->assertRedirect(route('login'));
expect(auth()->check())->toBeFalse();
fakeProvider(identity(email: 'stranger@example.test', verified: true, subject: 'other'));
signInWith();
expect(User::query()->where('email', 'stranger@example.test')->exists())->toBeFalse();
fakeProvider(identity(email: 'welcome@acme.test', verified: true, subject: 'inside'));
signInWith()->assertRedirect(route('dashboard'));
expect(auth()->user()->email)->toBe('welcome@acme.test')
->and($client->fresh()->email)->toBe('client@example.test');
});
/*
|--------------------------------------------------------------------------
| Account state and the second factor still apply
|--------------------------------------------------------------------------
*/
test('a deactivated account gets the ordinary message, not a social one', function () {
socialSettings();
$client = User::factory()->client()->create(['email' => 'client@example.test', 'active' => false]);
SocialAccount::query()->create([
'user_id' => $client->id,
'provider' => SocialProvider::Google->value,
'provider_user_id' => 'subject-1',
'email' => 'client@example.test',
]);
fakeProvider(identity(email: 'client@example.test'));
signInWith()->assertRedirect(route('login'))
->assertSessionHas('error', 'Your account has been deactivated.');
expect(auth()->check())->toBeFalse();
});
test('two-factor still challenges a provider sign-in', function () {
socialSettings();
$client = User::factory()->client()->create(['email' => 'client@example.test']);
$client->forceFill([
'two_factor_secret' => app(TwoFactorService::class)->generateSecret(),
'two_factor_confirmed_at' => now(),
])->save();
SocialAccount::query()->create([
'user_id' => $client->id,
'provider' => SocialProvider::Google->value,
'provider_user_id' => 'subject-1',
'email' => 'client@example.test',
]);
fakeProvider(identity(email: 'client@example.test'));
signInWith()->assertRedirect(route('two-factor.challenge'));
expect(auth()->check())->toBeFalse()
->and(session('two_factor.login_id'))->toBe($client->id);
});
/*
|--------------------------------------------------------------------------
| The callback is not an open door
|--------------------------------------------------------------------------
*/
test('a callback nobody started is refused without contacting the provider', function () {
socialSettings();
$fake = fakeProvider(identity());
$this->get(route('social.callback', ['provider' => 'google']))->assertRedirect(route('login'));
expect($fake->identityCalls)->toBe(0)
->and(auth()->check())->toBeFalse();
});
test('a callback for a different provider than the one begun is refused', function () {
socialSettings();
socialSettings(SocialProvider::Github);
$fake = fakeProvider(identity());
$this->get(route('social.redirect', ['provider' => 'google']));
$this->get(route('social.callback', ['provider' => 'github']))->assertRedirect(route('login'));
expect($fake->identityCalls)->toBe(0);
});
test('a disabled provider is never contacted', function () {
socialSettings(enabled: false);
$fake = fakeProvider(identity());
$this->get(route('social.redirect', ['provider' => 'google']))->assertRedirect(route('login'));
$this->get(route('social.callback', ['provider' => 'google']))->assertRedirect(route('login'));
expect($fake->redirects)->toBe(0)
->and($fake->identityCalls)->toBe(0);
});
test('an unknown provider is a 404', function () {
$this->get('/auth/myspace/redirect')->assertNotFound();
});
test('an identity with no email address cannot sign in', function () {
socialSettings(autoProvision: true, autoApprove: true);
fakeProvider(new SocialIdentity(SocialProvider::Google, 'subject-1', null, false, 'No Address'));
signInWith()->assertRedirect(route('login'));
expect(auth()->check())->toBeFalse();
});
/*
|--------------------------------------------------------------------------
| Staff may sign in, but are never created
|--------------------------------------------------------------------------
*/
test('a staff account signs in through a provider it has connected', function () {
socialSettings();
$staff = User::factory()->create(['email' => 'staff@example.test']);
SocialAccount::query()->create([
'user_id' => $staff->id,
'provider' => SocialProvider::Google->value,
'provider_user_id' => 'subject-1',
'email' => 'staff@example.test',
]);
fakeProvider(identity(email: 'staff@example.test'));
signInWith()->assertRedirect(route('dashboard'));
expect(auth()->id())->toBe($staff->id);
});
/*
|--------------------------------------------------------------------------
| A refusal has to be readable
|--------------------------------------------------------------------------
|
| Every refusal above redirects to /login carrying a flash. The app-wide
| Toaster is mounted in the authenticated layout, which the login page is
| not — so without the login page rendering this itself, a person would be
| bounced back to a blank form with no idea why. The assertion is that the
| message survives the redirect and reaches the page's props.
|
*/
test('a refusal reaches the login page rather than vanishing', function () {
socialSettings();
User::factory()->create(['email' => 'admin@example.test']);
fakeProvider(identity(email: 'admin@example.test', verified: false));
signInWith();
$this->followingRedirects()
->get(route('social.callback', ['provider' => 'google']))
->assertInertia(fn (AssertableInertia $page) => $page
->component('auth/login')
->where('flash.error', 'That sign-in could not be completed. Please try again.'));
});
test('the takeover refusal explains what to do instead', function () {
socialSettings();
User::factory()->create(['email' => 'admin@example.test']);
fakeProvider(identity(email: 'admin@example.test', verified: false));
$this->get(route('social.redirect', ['provider' => 'google']));
$this->followingRedirects()
->get(route('social.callback', ['provider' => 'google']))
->assertInertia(fn (AssertableInertia $page) => $page
->component('auth/login')
->where('flash.error', 'An account already uses this email address, and Google did not confirm that you own it. Sign in with your password and connect Google from your settings instead.'));
});