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'); }); 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']); Volt::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']); Volt::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)->first()->pivot->role)->toBe('member') ->and($fresh->companies()->withoutGlobalScopes()->whereKey($newCompany->id)->first()?->pivot->role)->toBe('responsible') ->and($fresh->can('update', $oldCompany))->toBeFalse() ->and($fresh->can('update', $newCompany))->toBeTrue(); });