presseportale/tests/Feature/Auth/ContactAccessTest.php
Kevin Adametz 2cde7284a1 livewire: Volt entfernt — Komponenten als Livewire-4-Single-File-Components, Route::livewire(), Livewire::test(); livewire/volt deinstalliert
- 83 Komponenten: Livewire\Volt\Component → Livewire\Component
- 73 Routen (admin, auth, customer): Volt::route() → Route::livewire()
- 88 Testdateien: Volt::test/actingAs → Livewire::test/actingAs
- VoltServiceProvider gelöscht; composer remove livewire/volt
- config/livewire.php auf v4-Schlüssel (component_layout, component_placeholder,
  component_locations, make_command: SFC ohne ⚡). Der alte Schlüssel layout war
  wirkungslos; alle gerouteten Komponenten setzen ihr Layout selbst.
- CLAUDE.md, AGENTS.md, boost.json, .cursorrules, Skill volt-development
- Roadmap: erledigt; Larastan erfasst SFCs weiterhin nicht (Blade-Dateien)

Suite 2.325 grün, einer übersprungen; Larastan und Pint ohne Meldung;
route:cache und view:cache laufen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:59:36 +00:00

485 lines
21 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\Enums\Portal;
use App\Enums\PressReleaseStatus;
use App\Mail\MagicLoginLink;
use App\Models\Company;
use App\Models\Contact;
use App\Models\PressRelease;
use App\Models\User;
use App\Services\Auth\ContactAccessService;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\URL;
use Livewire\Livewire;
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,
]);
Livewire::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');
Livewire::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);
Livewire::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,
]);
Livewire::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]);
Livewire::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 removed on the next login once the email basis is gone (Audit F10/U01, E7)', 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']);
Livewire::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();
Livewire::test('auth.login')->set('email', 'wechsel@example.test')->set('password', 'password')->call('login')->assertHasNoErrors();
$fresh = User::query()->findOrFail($user->id);
expect($fresh->companies()->withoutGlobalScopes()->whereKey($company->id)->exists())->toBeFalse()
->and($fresh->can('update', $company))->toBeFalse()
->and($fresh->can('view', $company))->toBeFalse();
});
test('after the email basis is gone a non-author can no longer edit or correct the company press releases (Audit U01, E7)', function () {
/** @var TestCase $this */
$author = User::factory()->create(['is_active' => true]);
$colleague = User::factory()->create(['email' => 'kollege@example.test', 'is_active' => true]);
$colleague->assignRole('customer');
$company = Company::factory()->presseecho()->create(['email' => 'kollege@example.test']);
$draft = PressRelease::factory()->forPortal(Portal::Presseecho)->for($company)->create(['user_id' => $author->id, 'status' => PressReleaseStatus::Draft]);
$published = PressRelease::factory()->published()->forPortal(Portal::Presseecho)->for($company)->create(['user_id' => $author->id]);
$own = PressRelease::factory()->forPortal(Portal::Presseecho)->for($company)->create(['user_id' => $colleague->id, 'status' => PressReleaseStatus::Draft]);
Livewire::test('auth.login')->set('email', 'kollege@example.test')->set('password', 'password')->call('login')->assertHasNoErrors();
$linked = User::query()->findOrFail($colleague->id);
expect($linked->can('update', $draft))->toBeTrue()
->and($linked->can('view', $published))->toBeTrue();
// Mitarbeiterwechsel: die Firma trägt eine andere Adresse.
$company->forceFill(['email' => 'nachfolger@example.test'])->save();
auth()->logout();
Livewire::test('auth.login')->set('email', 'kollege@example.test')->set('password', 'password')->call('login')->assertHasNoErrors();
$gone = User::query()->findOrFail($colleague->id);
expect($gone->can('update', $draft))->toBeFalse()
->and($gone->can('submitForReview', $draft))->toBeFalse()
->and($gone->can('delete', $draft))->toBeFalse()
->and($gone->can('view', $published))->toBeFalse()
->and($gone->can('update', $own))->toBeTrue();
// Die Korrektur-Action der Kundenseite hängt an `view` und findet die PM nicht mehr.
$this->actingAs($gone);
// Seit Livewire 4.4.7 als 404-Antwort statt geworfener ModelNotFoundException.
Livewire::test('customer.press-releases.show', ['id' => $published->id])->assertNotFound();
});
test('an automatically upgraded manual membership falls back to member instead of being removed (E7)', function () {
/** @var TestCase $this */
$user = User::factory()->create(['email' => 'team@example.test', 'is_active' => true]);
$company = Company::factory()->presseecho()->create(['email' => 'team@example.test']);
$user->companies()->attach($company->id, ['role' => 'member']);
app(ContactAccessService::class)->syncMembershipsForEmail($user);
$pivot = $user->companies()->withoutGlobalScopes()->whereKey($company->id)->first()->pivot;
expect($pivot->role)->toBe('responsible')->and($pivot->granted_via)->toBe('auto-upgrade');
$company->forceFill(['email' => 'andere@example.test'])->save();
app(ContactAccessService::class)->syncMembershipsForEmail($user->fresh());
$pivot = $user->companies()->withoutGlobalScopes()->whereKey($company->id)->first()->pivot;
expect($pivot->role)->toBe('member')->and($pivot->granted_via)->toBe('manual');
});
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');
Livewire::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');
});
test('a company email stored with capitals still links the lowercase account on login (Audit F22)', function () {
/** @var TestCase $this */
$user = User::factory()->create(['email' => 'gross@example.test', 'is_active' => true]);
$user->assignRole('customer');
$company = Company::factory()->presseecho()->create(['email' => 'Gross@Example.test']);
Livewire::test('auth.login')->set('email', 'gross@example.test')->set('password', 'password')->call('login')->assertHasNoErrors();
expect(User::query()->findOrFail($user->id)->accessibleCompanyIds())->toContain($company->id);
});
test('a confirmed email change re-syncs company rights immediately (Audit 7.1)', function () {
/** @var TestCase $this */
$user = User::factory()->create(['email' => 'alt@example.test', 'is_active' => true]);
$user->assignRole('customer');
$oldCompany = Company::factory()->presseecho()->create(['email' => 'alt@example.test']);
$newCompany = Company::factory()->presseecho()->create(['email' => 'neu@example.test']);
Livewire::test('auth.login')->set('email', 'alt@example.test')->set('password', 'password')->call('login')->assertHasNoErrors();
expect($user->fresh()->can('update', $oldCompany))->toBeTrue();
$user->forceFill(['pending_email' => 'neu@example.test'])->save();
$url = URL::temporarySignedRoute('email-change.confirm', now()->addHour(), ['user' => $user->id, 'hash' => sha1('neu@example.test')]);
$this->actingAs($user)->get($url)->assertRedirect(route('me.security'));
$fresh = User::query()->findOrFail($user->id);
expect($fresh->email)->toBe('neu@example.test')
->and($fresh->companies()->withoutGlobalScopes()->whereKey($oldCompany->id)->exists())->toBeFalse()
->and($fresh->companies()->withoutGlobalScopes()->whereKey($newCompany->id)->first()?->pivot->role)->toBe('responsible')
->and($fresh->can('update', $oldCompany))->toBeFalse()
->and($fresh->can('update', $newCompany))->toBeTrue();
});