seed(RolesAndPermissionsSeeder::class); }); /** * Simuliert die Google-Antwort. Die Rohdaten tragen wie der userinfo-Endpunkt * `email_verified` und – bei Workspace-Konten – `hd` (Sicherheitsaudit * 07.10.2026, S-05). */ function fakeGoogleUser(string $id, string $email, string $name, ?bool $emailVerified = true, ?string $hostedDomain = null): void { $raw = array_filter([ 'sub' => $id, 'email' => $email, 'name' => $name, 'email_verified' => $emailVerified, 'hd' => $hostedDomain, ], fn ($value) => $value !== null); $socialiteUser = (new SocialiteUser)->setRaw($raw)->map([ 'id' => $id, 'email' => $email, 'name' => $name, ]); $provider = Mockery::mock(Provider::class); $provider->shouldReceive('user')->andReturn($socialiteUser); Socialite::shouldReceive('driver')->with('google')->andReturn($provider); } test('a new google user is created active, verified and as customer', function () { /** @var TestCase $this */ fakeGoogleUser('g-new-1', 'new-google@example.test', 'Neu Google'); $this->get(route('oauth.google.callback')) ->assertRedirect(route('me.dashboard', absolute: false)); $user = User::query()->where('email', 'new-google@example.test')->firstOrFail(); expect($user->is_active)->toBeTrue(); expect($user->hasVerifiedEmail())->toBeTrue(); expect($user->hasRole('customer'))->toBeTrue(); expect($user->oauth_provider)->toBe('google'); expect($user->oauth_provider_id)->toBe('g-new-1'); expect($user->gdpr_consent_at)->not->toBeNull(); $this->assertAuthenticatedAs($user); }); test('an existing gmail user is linked by email without creating a duplicate', function () { /** @var TestCase $this */ Mail::fake(); $existing = User::factory()->create(['email' => 'existing-google@gmail.com', 'is_active' => true]); $existing->assignRole('customer'); fakeGoogleUser('g-existing-1', 'Existing-Google@gmail.com', 'Existing'); $this->get(route('oauth.google.callback')) ->assertRedirect(route('me.dashboard', absolute: false)); expect(User::query()->where('email', 'existing-google@gmail.com')->count())->toBe(1); $existing->refresh(); expect($existing->oauth_provider)->toBe('google'); expect($existing->oauth_provider_id)->toBe('g-existing-1'); $this->assertAuthenticatedAs($existing); Mail::assertQueued(SecurityChangeNotice::class, fn (SecurityChangeNotice $mail) => $mail->change === SecurityChange::GoogleLinked && $mail->hasTo('existing-google@gmail.com')); }); test('a deactivated verified account is not reactivated by google login', function () { /** @var TestCase $this */ $existing = User::factory()->create(['email' => 'deactivated@example.test', 'is_active' => false]); $existing->assignRole('customer'); fakeGoogleUser('g-deact-1', 'deactivated@example.test', 'Deactivated'); $this->get(route('oauth.google.callback')) ->assertRedirect(route('login')); expect($existing->fresh()->is_active)->toBeFalse(); $this->assertGuest(); }); test('a pending unverified registrant is onboarded via google', function () { /** @var TestCase $this */ $pending = User::factory()->unverified()->create([ 'email' => 'pending@example.test', 'is_active' => false, ]); fakeGoogleUser('g-pending-1', 'pending@example.test', 'Pending'); $this->get(route('oauth.google.callback')) ->assertRedirect(route('me.dashboard', absolute: false)); $pending->refresh(); expect($pending->is_active)->toBeTrue(); expect($pending->hasVerifiedEmail())->toBeTrue(); expect($pending->hasRole('customer'))->toBeTrue(); }); test('a failed google callback redirects back to login with an error', function () { /** @var TestCase $this */ $provider = Mockery::mock(Provider::class); $provider->shouldReceive('user')->andThrow(new RuntimeException('invalid state')); Socialite::shouldReceive('driver')->with('google')->andReturn($provider); $this->get(route('oauth.google.callback')) ->assertRedirect(route('login')); $this->assertGuest(); }); test('google login hands an account with confirmed two-factor to the challenge (Audit F02)', function () { /** @var TestCase $this */ $user = User::factory()->create(['email' => 'totp-google@example.test', 'is_active' => true]); $user->assignRole('customer'); $user->forceFill([ 'oauth_provider' => 'google', 'oauth_provider_id' => 'g-totp-1', 'two_factor_secret' => encrypt(app(Google2FA::class)->generateSecretKey()), 'two_factor_confirmed_at' => now(), ])->save(); fakeGoogleUser('g-totp-1', 'totp-google@example.test', 'Totp'); $this->get(route('oauth.google.callback')) ->assertRedirect(route('two-factor.challenge')); $this->assertGuest(); expect(session('login.id'))->toBe($user->id); expect($user->fresh()->last_login_at)->toBeNull(); }); /* * K-04 (Umsetzungsaudit 29.09.2026): Vorab-Übernahme. Ein Angreifer * registriert die Adresse der echten Pressestelle mit eigenem Passwort; die * Pressestelle meldet sich später mit Google an. Danach darf weder das * Passwort noch der Remember-Cookie noch eine offene Session des Angreifers * das Konto erreichen. */ test('a pre-registered account is no longer reachable with the old password, token or session after google verifies it', function () { /** @var TestCase $this */ $pending = User::factory()->unverified()->create([ 'email' => 'pressestelle@firma.test', 'password' => Hash::make('attacker-secret'), 'is_active' => false, 'remember_token' => 'attacker-remember-token', ]); $pending->createToken('attacker', ['press-releases:read']); // Offene Session des Angreifers (vor der Google-Anmeldung entstanden). $this->actingAs($pending); $this->flushSession(); fakeGoogleUser('g-takeover-1', 'pressestelle@firma.test', 'Pressestelle'); Auth::logout(); $this->get(route('oauth.google.callback')) ->assertRedirect(route('me.dashboard', absolute: false)); $pending->refresh(); expect($pending->password)->toBeNull() ->and($pending->remember_token)->not->toBe('attacker-remember-token') ->and($pending->tokens()->count())->toBe(0); // Die neue Google-Session bleibt gültig. $this->get(route('me.dashboard'))->assertOk(); $this->assertAuthenticatedAs($pending); // Die Session des Angreifers (ohne Anmeldezeitpunkt nach der Übernahme) nicht. Auth::logout(); $this->flushSession(); $this->actingAs($pending)->get(route('me.dashboard'))->assertRedirect(route('login')); $this->assertGuest(); // Und das alte Passwort führt nicht mehr hinein. Livewire::test('auth.login') ->set('email', 'pressestelle@firma.test') ->set('password', 'attacker-secret') ->call('login') ->assertHasErrors(['email']); $this->assertGuest(); }); /* * S-05 (Sicherheitsaudit 07.10.2026): Google-Identität fest binden und die * Erstverknüpfung bestehender Konten nur bei Googles Zuständigkeit für das * Postfach direkt zulassen. */ test('google login is rejected without a confirmed email address', function (?bool $emailVerified) { /** @var TestCase $this */ fakeGoogleUser('g-unverified-1', 'unverified-claim@gmail.com', 'Unverified', $emailVerified); $this->get(route('oauth.google.callback')) ->assertRedirect(route('login')) ->assertSessionHasErrors('email'); $this->assertGuest(); expect(User::query()->where('email', 'unverified-claim@gmail.com')->exists())->toBeFalse(); })->with([ 'email_verified false' => false, 'claim missing' => null, ]); test('an account linked to another google identity is not relinked', function () { /** @var TestCase $this */ $user = User::factory()->create([ 'email' => 'linked@gmail.com', 'is_active' => true, 'oauth_provider' => 'google', 'oauth_provider_id' => 'g-original', ]); $user->assignRole('customer'); fakeGoogleUser('g-other', 'linked@gmail.com', 'Other'); $this->get(route('oauth.google.callback')) ->assertRedirect(route('login')) ->assertSessionHasErrors('email'); $this->assertGuest(); expect($user->fresh()->oauth_provider_id)->toBe('g-original'); }); test('a linked google identity signs in even after its google address changed', function () { /** @var TestCase $this */ $user = User::factory()->create([ 'email' => 'old-address@firma.test', 'is_active' => true, 'oauth_provider' => 'google', 'oauth_provider_id' => 'g-stable', ]); $user->assignRole('customer'); fakeGoogleUser('g-stable', 'new-address@gmail.com', 'Stable'); $this->get(route('oauth.google.callback')) ->assertRedirect(route('me.dashboard', absolute: false)); $this->assertAuthenticatedAs($user); expect($user->fresh()->email)->toBe('old-address@firma.test'); }); test('an existing account with an external address is only linked after mailbox confirmation in the same browser', function () { /** @var TestCase $this */ Mail::fake(); $user = User::factory()->create(['email' => 'presse@firma.test', 'is_active' => true]); $user->assignRole('customer'); fakeGoogleUser('g-external', 'presse@firma.test', 'Presse'); $this->get(route('oauth.google.callback')) ->assertRedirect(route('login')) ->assertSessionHas('status'); $this->assertGuest(); expect($user->fresh()->oauth_provider_id)->toBeNull(); $confirmUrl = null; Mail::assertQueued(SocialAccountLinkConfirmation::class, function (SocialAccountLinkConfirmation $mail) use (&$confirmUrl) { $confirmUrl = $mail->confirmUrl; return $mail->hasTo('presse@firma.test'); }); $this->get($confirmUrl)->assertRedirect(route('me.dashboard', absolute: false)); $this->assertAuthenticatedAs($user); expect($user->fresh()->oauth_provider_id)->toBe('g-external'); Mail::assertQueued(SecurityChangeNotice::class, fn (SecurityChangeNotice $mail) => $mail->change === SecurityChange::GoogleLinked); }); test('the link confirmation does nothing in a browser that did not start the google login', function () { /** @var TestCase $this */ Mail::fake(); $user = User::factory()->create(['email' => 'presse2@firma.test', 'is_active' => true]); $user->assignRole('customer'); fakeGoogleUser('g-attacker', 'presse2@firma.test', 'Angreifer'); $this->get(route('oauth.google.callback')); $confirmUrl = null; Mail::assertQueued(SocialAccountLinkConfirmation::class, function (SocialAccountLinkConfirmation $mail) use (&$confirmUrl) { $confirmUrl = $mail->confirmUrl; return true; }); // Der Postfachinhaber klickt die Mail in seinem eigenen Browser an. $this->flushSession(); $this->get($confirmUrl) ->assertRedirect(route('login')) ->assertSessionHasErrors('email'); $this->assertGuest(); expect($user->fresh()->oauth_provider_id)->toBeNull(); }); test('a tampered link confirmation is rejected', function () { /** @var TestCase $this */ Mail::fake(); $user = User::factory()->create(['email' => 'presse3@firma.test', 'is_active' => true]); $user->assignRole('customer'); fakeGoogleUser('g-real', 'presse3@firma.test', 'Presse'); $this->get(route('oauth.google.callback')); $confirmUrl = null; Mail::assertQueued(SocialAccountLinkConfirmation::class, function (SocialAccountLinkConfirmation $mail) use (&$confirmUrl) { $confirmUrl = $mail->confirmUrl; return true; }); $this->get(str_replace('g-real', 'g-forged', $confirmUrl))->assertForbidden(); expect($user->fresh()->oauth_provider_id)->toBeNull(); }); test('a workspace account is linked directly when hd matches the address domain', function (string $hostedDomain, bool $linkedDirectly) { /** @var TestCase $this */ Mail::fake(); $user = User::factory()->create(['email' => 'team@workspace-firma.test', 'is_active' => true]); $user->assignRole('customer'); fakeGoogleUser('g-workspace', 'team@workspace-firma.test', 'Team', hostedDomain: $hostedDomain); $this->get(route('oauth.google.callback')); if ($linkedDirectly) { $this->assertAuthenticatedAs($user); expect($user->fresh()->oauth_provider_id)->toBe('g-workspace'); Mail::assertNotQueued(SocialAccountLinkConfirmation::class); } else { $this->assertGuest(); expect($user->fresh()->oauth_provider_id)->toBeNull(); Mail::assertQueued(SocialAccountLinkConfirmation::class); } })->with([ 'matching hd' => ['workspace-firma.test', true], 'foreign hd' => ['andere-firma.test', false], ]);