presseportale/tests/Feature/Auth/ContactAccessTest.php
Kevin Adametz 301bca3a90 auth: automatische Firmenrechte mit Herkunft und Entzug, Nachweis vor Sicherheitsaktionen (Audit Block 1, Deploy C)
F10: company_user.granted_via unterscheidet automatisch vergebene
Rechte (E-Mail-Sync) von manuellen und aus dem Altbestand. Beim Login
senkt der Sync automatische responsible-Rechte ohne aktuellen E-Mail-
Treffer auf member (E2) — Mitarbeiterwechsel nehmen den Schreibzugriff
mit, Lesezugriff bleibt. Das Admin-Formular markiert nur neue oder
geänderte Zeilen als manual, unveränderte behalten ihre Herkunft.

Bestandsprüfung: php artisan company-memberships:audit listet
responsible-Zuordnungen ohne E-Mail-Grundlage (Tabelle ab Go-Live, CSV
vollständig) und markiert mit --classify die erklärbaren als auto. Auf
Live nach der Migration einmal mit --classify laufen lassen; nicht
erklärbare Zeilen bleiben manual und damit geschützt.

F03 (E3): Zwei-Faktor deaktivieren, Wiederherstellungs-Codes erneuern
und die E-Mail-Änderung verlangen einen aktuellen App-Code oder einen
Wiederherstellungs-Code (der dabei verbraucht wird); ohne 2FA das
Passwort. Reine Magic-Link-Konten ohne Passwort (der Altbestand) können
die Adresse weiter ändern — die neue Adresse muss ihren Bestätigungslink
ohnehin einlösen. Fünf Fehlversuche pro Minute.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 10:19:28 +00:00

394 lines
16 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();
});
test('an unverified email earns no company membership on password login (Audit F01)', function () {
/** @var TestCase $this */
// Ein Konto hat seine Adresse auf eine fremde Firmen-Adresse gewechselt
// und ist deshalb unverifiziert – der Login-Sync darf daraus keine
// Firmenrechte ableiten, solange das Postfach nicht nachgewiesen ist.
$user = User::factory()->unverified()->create(['email' => 'fremd@example.test', 'is_active' => true]);
$user->assignRole('customer');
$company = Company::factory()->presseecho()->create(['email' => 'fremd@example.test']);
Contact::factory()->for($company)->create(['email' => 'fremd@example.test', 'portal' => $company->portal->value]);
Volt::test('auth.login')
->set('email', 'fremd@example.test')
->set('password', 'password')
->call('login')
->assertRedirect(route('verification.notice', absolute: false));
expect($user->fresh()->accessibleCompanyIds())->not->toContain($company->id);
expect($user->contacts()->count())->toBe(0);
// Nach Verifikation greift der Sync beim nächsten Login wie bisher.
$user->forceFill(['email_verified_at' => now()])->save();
app(ContactAccessService::class)->syncMembershipsForEmail($user->fresh());
expect($user->fresh()->accessibleCompanyIds())->toContain($company->id);
});
test('the membership sync ignores unverified and deactivated users regardless of the caller (Audit F01)', function () {
/** @var TestCase $this */
$company = Company::factory()->presseecho()->create(['email' => 'sync-guard@example.test']);
$unverified = User::factory()->unverified()->create(['email' => 'sync-guard@example.test', 'is_active' => true]);
app(ContactAccessService::class)->syncMembershipsForEmail($unverified);
expect($unverified->fresh()->accessibleCompanyIds())->not->toContain($company->id);
$unverified->forceDelete();
$inactive = User::factory()->create(['email' => 'sync-guard@example.test', 'is_active' => false]);
app(ContactAccessService::class)->syncMembershipsForEmail($inactive);
expect($inactive->fresh()->accessibleCompanyIds())->not->toContain($company->id);
});
test('an auto-granted responsible role is lowered to member on the next login once the email basis is gone (Audit F10)', function () {
/** @var TestCase $this */
$user = User::factory()->create(['email' => 'wechsel@example.test', 'is_active' => true]);
$user->assignRole('customer');
$company = Company::factory()->presseecho()->create(['email' => 'wechsel@example.test']);
Volt::test('auth.login')->set('email', 'wechsel@example.test')->set('password', 'password')->call('login')->assertHasNoErrors();
$pivot = $user->fresh()->companies()->withoutGlobalScopes()->whereKey($company->id)->first()->pivot;
expect($pivot->role)->toBe('responsible')
->and($pivot->granted_via)->toBe('auto')
->and($user->fresh()->can('update', $company))->toBeTrue();
// Mitarbeiterwechsel: die Firma trägt jetzt eine andere Adresse.
$company->forceFill(['email' => 'nachfolger@example.test'])->save();
auth()->logout();
Volt::test('auth.login')->set('email', 'wechsel@example.test')->set('password', 'password')->call('login')->assertHasNoErrors();
$fresh = User::query()->findOrFail($user->id);
$pivot = $fresh->companies()->withoutGlobalScopes()->whereKey($company->id)->first()->pivot;
expect($pivot->role)->toBe('member')
->and($fresh->can('update', $company))->toBeFalse()
->and($fresh->can('view', $company))->toBeTrue();
});
test('a manually granted responsible role survives the login sync without an email match (Audit F10)', function () {
/** @var TestCase $this */
$user = User::factory()->create(['email' => 'manuell@example.test', 'is_active' => true]);
$user->assignRole('customer');
$company = Company::factory()->presseecho()->create(['email' => 'andere@example.test']);
$user->syncCompaniesWithRoles([$company->id], [$company->id => 'responsible']);
expect($user->companies()->withoutGlobalScopes()->whereKey($company->id)->first()->pivot->granted_via)->toBe('manual');
Volt::test('auth.login')->set('email', 'manuell@example.test')->set('password', 'password')->call('login')->assertHasNoErrors();
expect(User::query()->findOrFail($user->id)->can('update', $company))->toBeTrue();
});
test('the admin form keeps the auto origin of untouched rows and marks changed roles as manual (Audit F10)', function () {
/** @var TestCase $this */
$user = User::factory()->create(['email' => 'form@example.test', 'is_active' => true]);
$auto = Company::factory()->presseecho()->create(['email' => 'form@example.test']);
$other = Company::factory()->presseecho()->create();
app(ContactAccessService::class)->syncMembershipsForEmail($user);
// Unverändert speichern → Herkunft bleibt auto; Rolle ändern → manual.
$user->syncCompaniesWithRoles([$auto->id, $other->id], [$auto->id => 'responsible', $other->id => 'member']);
$pivots = $user->companies()->withoutGlobalScopes()->get()->keyBy('id');
expect($pivots[$auto->id]->pivot->granted_via)->toBe('auto')
->and($pivots[$other->id]->pivot->granted_via)->toBe('manual');
$user->syncCompaniesWithRoles([$auto->id], [$auto->id => 'owner']);
expect($user->companies()->withoutGlobalScopes()->whereKey($auto->id)->first()->pivot->granted_via)->toBe('manual');
});