auth: automatische Firmenzuordnung beim Wegfall der E-Mail-Grundlage entfernen statt senken (Block 7.13, U01, E7)

- Login-Sync löscht auto-Pivots ohne aktuellen Treffer; member wäre die
  Teamrolle mit Schreibrecht auf alle Firmen-PMs, keine Leserolle
- hochgestufte manuelle Mitgliedschaften tragen granted_via=auto-upgrade
  und fallen auf member zurück; manual und owner bleiben unberührt
- company-memberships:audit --classify stuft nur manual-Zeilen um
- Plan: E2 revidiert, E7 entschieden, 1.5 und 7.13 nachgezogen

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-18 13:00:48 +00:00
parent 3ea9688c9a
commit b8c6af5f10
4 changed files with 92 additions and 26 deletions

View file

@ -1,11 +1,15 @@
<?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\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\URL;
use Livewire\Volt\Volt;
@ -334,7 +338,7 @@ test('the membership sync ignores unverified and deactivated users regardless of
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 () {
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');
@ -355,10 +359,62 @@ test('an auto-granted responsible role is lowered to member on the next login on
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')
expect($fresh->companies()->withoutGlobalScopes()->whereKey($company->id)->exists())->toBeFalse()
->and($fresh->can('update', $company))->toBeFalse()
->and($fresh->can('view', $company))->toBeTrue();
->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]);
Volt::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();
Volt::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);
expect(fn () => Volt::test('customer.press-releases.show', ['id' => $published->id]))
->toThrow(ModelNotFoundException::class);
});
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 () {
@ -423,7 +479,7 @@ test('a confirmed email change re-syncs company rights immediately (Audit 7.1)',
$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($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();