presseportale/tests/Feature/Auth/ContactAccessTest.php
Kevin Adametz ea8d017445 auth/mail: Magic-Link-2FA (M1) + Mailversand über die Queue
- Magic-Link-Login übergibt bei aktiviertem 2FA an die 2FA-Challenge,
  statt sie zu umgehen (M1).
- Bestätigungs-/Benachrichtigungs-Mails (Kontakt, Recht, Kündigung,
  Widerruf, Magic-Link) laufen als ShouldQueue+afterCommit über die Queue,
  damit die Response nicht am SMTP-Roundtrip hängt.

Hinweis: Der Queue-Worker muss im Betrieb laufen und überwacht werden, da
fristrelevante Bestätigungen darüber zugestellt werden (siehe
Betriebsüberwachung).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 13:49:21 +00:00

292 lines
10 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
use App\Mail\MagicLoginLink;
use App\Models\Company;
use App\Models\Contact;
use App\Models\User;
use App\Services\Auth\ContactAccessService;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\Mail;
use Livewire\Volt\Volt;
use Tests\TestCase;
beforeEach(function () {
$this->seed(RolesAndPermissionsSeeder::class);
});
test('requesting access for an unknown email creates nothing and sends no mail', function () {
/** @var TestCase $this */
Mail::fake();
$sent = app(ContactAccessService::class)->requestAccess('nobody@example.test');
expect($sent)->toBeFalse();
expect(User::query()->where('email', 'nobody@example.test')->exists())->toBeFalse();
Mail::assertNothingOutgoing();
});
test('requesting access for a known contact lazily creates a scoped account and sends a link', function () {
/** @var TestCase $this */
Mail::fake();
$company = Company::factory()->presseecho()->create();
Contact::factory()->for($company)->create([
'email' => 'paula@example.test',
'first_name' => 'Paula',
'last_name' => 'Presse',
'portal' => $company->portal->value,
]);
// Gross-/Kleinschreibung egal.
$sent = app(ContactAccessService::class)->requestAccess('Paula@Example.test');
expect($sent)->toBeTrue();
$user = User::query()->where('email', 'paula@example.test')->firstOrFail();
expect($user->name)->toBe('Paula Presse');
expect($user->is_active)->toBeTrue();
expect($user->hasVerifiedEmail())->toBeTrue();
expect($user->hasRole('customer'))->toBeTrue();
expect($user->canAccessAdmin())->toBeFalse();
expect($user->canAccessCustomer())->toBeTrue();
expect($user->accessibleCompanyIds())->toContain($company->id);
expect($user->gdpr_consent_at)->not->toBeNull();
Mail::assertQueued(MagicLoginLink::class, fn (MagicLoginLink $mail) => $mail->user->is($user));
});
test('requesting access reuses an existing user without creating a duplicate', function () {
/** @var TestCase $this */
Mail::fake();
$company = Company::factory()->presseecho()->create();
Contact::factory()->for($company)->create([
'email' => 'existing@example.test',
'portal' => $company->portal->value,
]);
$existing = User::factory()->create(['email' => 'existing@example.test', 'is_active' => true]);
$existing->assignRole('customer');
app(ContactAccessService::class)->requestAccess('existing@example.test');
expect(User::query()->where('email', 'existing@example.test')->count())->toBe(1);
expect($existing->fresh()->accessibleCompanyIds())->toContain($company->id);
Mail::assertQueued(MagicLoginLink::class);
});
test('the magic link form responds neutrally and triggers the service for a contact', function () {
/** @var TestCase $this */
Mail::fake();
$company = Company::factory()->presseecho()->create();
Contact::factory()->for($company)->create([
'email' => 'form@example.test',
'portal' => $company->portal->value,
]);
Volt::test('auth.magic-link')
->set('email', 'form@example.test')
->call('requestLink')
->assertHasNoErrors()
->assertSet('email', '');
Mail::assertQueued(MagicLoginLink::class);
expect(User::query()->where('email', 'form@example.test')->exists())->toBeTrue();
});
test('the magic link form sends a link to an existing active user without a contact', function () {
/** @var TestCase $this */
Mail::fake();
$user = User::factory()->create(['email' => 'plain@example.test', 'is_active' => true]);
$user->assignRole('customer');
Volt::test('auth.magic-link')
->set('email', 'plain@example.test')
->call('requestLink')
->assertHasNoErrors()
->assertSet('email', '');
Mail::assertQueued(MagicLoginLink::class, fn (MagicLoginLink $mail) => $mail->user->is($user));
});
test('requestMagicAccess does nothing for an unknown email', function () {
/** @var TestCase $this */
Mail::fake();
$sent = app(ContactAccessService::class)->requestMagicAccess('nobody@example.test');
expect($sent)->toBeFalse();
expect(User::query()->where('email', 'nobody@example.test')->exists())->toBeFalse();
Mail::assertNothingOutgoing();
});
test('requestMagicAccess does not send to an inactive existing user', function () {
/** @var TestCase $this */
Mail::fake();
User::factory()->create(['email' => 'off@example.test', 'is_active' => false]);
$sent = app(ContactAccessService::class)->requestMagicAccess('off@example.test');
expect($sent)->toBeFalse();
Mail::assertNothingOutgoing();
});
test('a later-added company with the same email is linked on the next password login', function () {
/** @var TestCase $this */
$user = User::factory()->create(['email' => 'sync@example.test', 'is_active' => true]);
$user->assignRole('customer');
// Firma kommt NACH dem Account dazu (z. B. per API) und trägt dieselbe E-Mail.
$company = Company::factory()->presseecho()->create(['email' => 'sync@example.test']);
expect($user->accessibleCompanyIds())->not->toContain($company->id);
Volt::test('auth.login')
->set('email', 'sync@example.test')
->set('password', 'password')
->call('login')
->assertHasNoErrors();
// Der Login-Hook (SyncCompanyMembershipsOnLogin) hat die Firma verknüpft.
expect($user->fresh()->accessibleCompanyIds())->toContain($company->id);
});
test('a magic-link account can manage (edit) the company it was linked to', function () {
/** @var TestCase $this */
Mail::fake();
$company = Company::factory()->presseecho()->create(['email' => 'verantwortlich@example.test']);
app(ContactAccessService::class)->requestMagicAccess('verantwortlich@example.test');
$user = User::query()->where('email', 'verantwortlich@example.test')->firstOrFail();
// Schreibzugriff (CompanyPolicy::update) – nicht nur Lesezugriff.
expect($user->can('update', $company))->toBeTrue();
expect($user->can('view', $company))->toBeTrue();
});
test('an existing read-only member is upgraded to responsible on magic access', function () {
/** @var TestCase $this */
Mail::fake();
$company = Company::factory()->presseecho()->create(['email' => 'upgrade@example.test']);
$user = User::factory()->create(['email' => 'upgrade@example.test', 'is_active' => true]);
$user->assignRole('customer');
// Vorher nur Lesezugriff.
$user->companies()->attach($company->id, ['role' => 'member']);
expect($user->can('update', $company))->toBeFalse();
app(ContactAccessService::class)->requestMagicAccess('upgrade@example.test');
expect($user->fresh()->can('update', $company))->toBeTrue();
// Genau ein Pivot-Eintrag, jetzt 'responsible'.
expect($user->companies()->withoutGlobalScopes()->whereKey($company->id)->count())->toBe(1);
});
test('a company email lazily creates an account scoped to that company', function () {
/** @var TestCase $this */
Mail::fake();
$company = Company::factory()->presseecho()->create([
'email' => 'firma@example.test',
'name' => 'Firma AG',
]);
// Gross-/Kleinschreibung egal.
$sent = app(ContactAccessService::class)->requestMagicAccess('Firma@Example.test');
expect($sent)->toBeTrue();
$user = User::query()->where('email', 'firma@example.test')->firstOrFail();
expect($user->is_active)->toBeTrue();
expect($user->hasVerifiedEmail())->toBeTrue();
expect($user->hasRole('customer'))->toBeTrue();
// Kein Kontaktname hinterlegt → Firmenname als Account-Name.
expect($user->name)->toBe('Firma AG');
expect($user->accessibleCompanyIds())->toContain($company->id);
Mail::assertQueued(MagicLoginLink::class, fn (MagicLoginLink $mail) => $mail->user->is($user));
});
test('one account is created for an email stored across multiple companies and contacts', function () {
/** @var TestCase $this */
Mail::fake();
// Dieselbe E-Mail an zwei Firmen (verschiedene Portale) direkt hinterlegt …
$companyA = Company::factory()->presseecho()->create(['email' => 'multi@example.test']);
$companyB = Company::factory()->businessportal24()->create(['email' => 'multi@example.test']);
// … und zusätzlich als Pressekontakt einer dritten Firma.
$companyC = Company::factory()->presseecho()->create(['email' => 'andere@example.test']);
Contact::factory()->for($companyC)->create([
'email' => 'multi@example.test',
'portal' => $companyC->portal->value,
]);
$sent = app(ContactAccessService::class)->requestMagicAccess('multi@example.test');
expect($sent)->toBeTrue();
// Genau EIN Account für die E-Mail.
expect(User::query()->where('email', 'multi@example.test')->count())->toBe(1);
$user = User::query()->where('email', 'multi@example.test')->firstOrFail();
$ids = $user->accessibleCompanyIds();
// ALLE drei Firmen zugeordnet (zwei per Firmen-E-Mail, eine per Kontakt),
// portalübergreifend.
expect($ids)->toContain($companyA->id);
expect($ids)->toContain($companyB->id);
expect($ids)->toContain($companyC->id);
Mail::assertQueued(MagicLoginLink::class, 1);
});
test('requesting access does not mutate an existing inactive account', function () {
/** @var TestCase $this */
Mail::fake();
$company = Company::factory()->presseecho()->create();
Contact::factory()->for($company)->create([
'email' => 'inactive@example.test',
'portal' => $company->portal->value,
]);
$existing = User::factory()->create(['email' => 'inactive@example.test', 'is_active' => false]);
$existing->assignRole('customer');
$sent = app(ContactAccessService::class)->requestAccess('inactive@example.test');
expect($sent)->toBeFalse();
// Keine Firmenzuordnung an einem deaktivierten Bestands-Account.
expect($existing->fresh()->accessibleCompanyIds())->not->toContain($company->id);
Mail::assertNothingOutgoing();
});
test('the honeypot blocks bots without creating an account', function () {
/** @var TestCase $this */
Mail::fake();
$company = Company::factory()->presseecho()->create();
Contact::factory()->for($company)->create([
'email' => 'bot-target@example.test',
'portal' => $company->portal->value,
]);
Volt::test('auth.magic-link')
->set('email', 'bot-target@example.test')
->set('website', 'http://spam.example')
->call('requestLink')
->assertHasNoErrors();
Mail::assertNothingOutgoing();
expect(User::query()->where('email', 'bot-target@example.test')->exists())->toBeFalse();
});