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>
This commit is contained in:
parent
a7f991f01e
commit
62202a4b13
10 changed files with 596 additions and 24 deletions
53
tests/Feature/Auth/CompanyMembershipAuditTest.php
Normal file
53
tests/Feature/Auth/CompanyMembershipAuditTest.php
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Company;
|
||||
use App\Models\Contact;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Bestandsprüfung der `responsible`-Zuordnungen (Audit F01/F10, Plan 1.4):
|
||||
* Welche Verantwortlichkeiten lassen sich heute noch über eine hinterlegte
|
||||
* E-Mail erklären, welche nicht – und `--classify` markiert die erklärbaren
|
||||
* als automatisch vergeben, damit der Login-Sync sie später zurücknehmen kann.
|
||||
*/
|
||||
test('the audit lists unexplained responsible links and classifies explained ones on request', function () {
|
||||
/** @var TestCase $this */
|
||||
Storage::fake('local');
|
||||
|
||||
$viaCompany = User::factory()->create(['email' => 'firma@example.test']);
|
||||
$viaContact = User::factory()->create(['email' => 'kontakt@example.test']);
|
||||
$orphan = User::factory()->create(['email' => 'niemand@example.test']);
|
||||
|
||||
$companyA = Company::factory()->presseecho()->create(['email' => 'firma@example.test', 'name' => 'Erklärbar AG']);
|
||||
$companyB = Company::factory()->presseecho()->create(['email' => 'andere@example.test', 'name' => 'Kontakt GmbH']);
|
||||
Contact::factory()->for($companyB)->create(['email' => 'Kontakt@Example.test', 'portal' => $companyB->portal->value]);
|
||||
$companyC = Company::factory()->presseecho()->create(['email' => 'dritte@example.test', 'name' => 'Verwaist KG']);
|
||||
|
||||
$viaCompany->companies()->attach($companyA->id, ['role' => 'responsible']);
|
||||
$viaContact->companies()->attach($companyB->id, ['role' => 'responsible']);
|
||||
$orphan->companies()->attach($companyC->id, ['role' => 'responsible']);
|
||||
$orphan->companies()->attach($companyA->id, ['role' => 'owner']);
|
||||
|
||||
$this->artisan('company-memberships:audit', ['--since' => '2000-01-01'])
|
||||
->expectsOutputToContain('Verwaist KG')
|
||||
->doesntExpectOutputToContain('Erklärbar AG')
|
||||
->doesntExpectOutputToContain('Kontakt GmbH')
|
||||
->assertSuccessful();
|
||||
|
||||
// Ohne --classify bleibt alles manual.
|
||||
expect(DB::table('company_user')->where('granted_via', 'auto')->count())->toBe(0);
|
||||
|
||||
$this->artisan('company-memberships:audit', ['--classify' => true, '--since' => '2000-01-01'])->assertSuccessful();
|
||||
|
||||
$via = fn (int $userId, int $companyId): string => DB::table('company_user')->where('user_id', $userId)->where('company_id', $companyId)->value('granted_via');
|
||||
|
||||
expect($via($viaCompany->id, $companyA->id))->toBe('auto')
|
||||
->and($via($viaContact->id, $companyB->id))->toBe('auto')
|
||||
->and($via($orphan->id, $companyC->id))->toBe('manual')
|
||||
->and($via($orphan->id, $companyA->id))->toBe('manual');
|
||||
|
||||
expect(collect(Storage::disk('local')->files())->filter(fn (string $f) => str_starts_with($f, 'company-memberships-unexplained-')))->toHaveCount(1);
|
||||
});
|
||||
|
|
@ -332,3 +332,63 @@ test('the membership sync ignores unverified and deactivated users regardless of
|
|||
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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue