presseportale/tests/Feature/Auth/AutoMembershipRevocationTest.php
Kevin Adametz bae8347f6c security: Sicherheitsaudit 07.10.2026 (S-01–S-06) behoben, Passkeys und Zweitfaktor-Pflicht für Admins
Audit: dev/sicherheit/SICHERHEITSAUDIT-2026-10-07.md, Plan und Entscheidungen:
dev/sicherheit/ENTWICKLUNGSPLAN-SICHERHEIT-2026-10-07.md.

- S-01 Legacy-Rechnungen fremder Kunden nur mit billing:manage (auch Kundenroute).
- S-02 Kontaktzuordnung auf der Firmenanzeige verlangt companies:write, $id gesperrt.
- S-03 CompanyMembershipObserver: automatische Firmenrechte werden bei jeder
  Adress-/Zuordnungsänderung sofort neu bewertet; memberships:resync-auto.
- S-04 Passwortwechsel beendet alle anderen Sessions (PasswordChanger), Reset
  alle Sessions und offene Login-Links; Fortify PUT /user/password abgeschaltet.
  API-Tokens bleiben bestehen.
- S-05 Google: email_verified Pflicht, feste Bindung über die Provider-ID;
  Erstverknüpfung bestehender Konten außerhalb gmail/hd nur per Bestätigungslink
  in derselben Browser-Session.
- S-06 Frischer Identitätsnachweis (Passwort, App-Code, E-Mail-Code oder
  Passkey) vor E-Mail-Wechsel, erster 2FA-Einrichtung und Token-Erzeugung;
  Hinweis-Mails an die (bisherige) Kontoadresse.
- Entscheidung 5: Passkeys (Fortify, @laravel/passkeys) für alle; Super-Admins
  und Admins bestätigen die Session für den Admin-Bereich per Passkey oder
  App-Code (EnsureStrongAdminAuthentication, Notfallschalter
  AUTH_REQUIRE_STRONG_ADMIN); Reset des zweiten Faktors durch Super-Admins.

Neue Migration: create_passkeys_table. Suite 2.403 grün (1 übersprungen),
Larastan ohne Meldung.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:09:35 +00:00

142 lines
6 KiB
PHP

<?php
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\DB;
use Tests\TestCase;
/**
* Sicherheitsaudit 07.10.2026, S-03: Automatisch über eine Kontakt- oder
* Firmenadresse vergebene Firmenrechte enden, sobald ihre Grundlage
* wegfällt — in der laufenden Session, nicht erst beim nächsten Login.
* Manuelle Mitgliedschaften bleiben.
*/
beforeEach(function () {
$this->seed(RolesAndPermissionsSeeder::class);
});
/**
* @return array{0: User, 1: Company, 2: Contact}
*/
function memberViaContact(string $email = 'ex-mitarbeiterin@firma.test'): array
{
$user = User::factory()->create(['email' => $email, 'is_active' => true]);
$user->assignRole('customer');
$company = Company::factory()->presseecho()->create(['email' => 'zentrale@firma.test']);
$contact = Contact::factory()->for($company)->create(['email' => $email, 'portal' => $company->portal->value]);
app(ContactAccessService::class)->syncMembershipsForEmail($user);
return [$user, $company, $contact];
}
function membershipOf(User $user, Company $company): ?object
{
return DB::table('company_user')->where('user_id', $user->id)->where('company_id', $company->id)->first();
}
test('deleting the contact ends the automatic access within the running session', function () {
/** @var TestCase $this */
[$user, $company, $contact] = memberViaContact();
expect(membershipOf($user, $company)?->granted_via)->toBe('auto');
$this->actingAs($user)->get(route('me.press-kits.show', $company->id))->assertSuccessful();
$contact->delete();
expect(membershipOf($user, $company))->toBeNull();
$this->actingAs($user->fresh())->get(route('me.press-kits.show', $company->id))->assertNotFound();
});
test('changing or moving the contact ends the automatic access', function (Closure $change) {
[$user, $company, $contact] = memberViaContact();
$change($contact);
expect(membershipOf($user, $company))->toBeNull()
->and($user->fresh()->canAccessCompany($company->id))->toBeFalse();
})->with([
'email changed' => fn (Contact $contact) => $contact->update(['email' => 'nachfolger@firma.test']),
'moved to another company' => fn (Contact $contact) => $contact->update([
'company_id' => Company::factory()->presseecho()->create()->id,
]),
]);
test('a contact moved into a company grants its automatic access right away', function () {
$user = User::factory()->create(['email' => 'neu@firma.test', 'is_active' => true]);
$user->assignRole('customer');
$source = Company::factory()->presseecho()->create();
$target = Company::factory()->presseecho()->create();
$contact = Contact::factory()->for($source)->create(['email' => 'neu@firma.test', 'portal' => $source->portal->value]);
app(ContactAccessService::class)->syncMembershipsForEmail($user);
$contact->update(['company_id' => $target->id]);
expect(membershipOf($user, $source))->toBeNull()
->and(membershipOf($user, $target)?->granted_via)->toBe('auto');
});
test('changing the company address ends access granted through it', function () {
$user = User::factory()->create(['email' => 'zentrale@firma.test', 'is_active' => true]);
$user->assignRole('customer');
$company = Company::factory()->presseecho()->create(['email' => 'zentrale@firma.test']);
app(ContactAccessService::class)->syncMembershipsForEmail($user);
expect(membershipOf($user, $company)?->granted_via)->toBe('auto');
$company->update(['email' => 'info@firma.test']);
expect(membershipOf($user, $company))->toBeNull();
});
test('access stays while a second contact with the same address remains', function () {
[$user, $company, $contact] = memberViaContact();
Contact::factory()->for($company)->create(['email' => $user->email, 'portal' => $company->portal->value]);
$contact->delete();
expect(membershipOf($user, $company)?->granted_via)->toBe('auto');
});
test('manual memberships survive and upgraded ones fall back to member', function () {
$manual = User::factory()->create(['email' => 'manuell@firma.test', 'is_active' => true]);
$upgraded = User::factory()->create(['email' => 'team@firma.test', 'is_active' => true]);
$company = Company::factory()->presseecho()->create();
$manualContact = Contact::factory()->for($company)->create(['email' => 'manuell@firma.test', 'portal' => $company->portal->value]);
$teamContact = Contact::factory()->for($company)->create(['email' => 'team@firma.test', 'portal' => $company->portal->value]);
$manual->companies()->attach($company->id, ['role' => 'responsible', 'granted_via' => 'manual']);
$upgraded->companies()->attach($company->id, ['role' => 'member', 'granted_via' => 'manual']);
app(ContactAccessService::class)->syncMembershipsForEmail($upgraded);
expect(membershipOf($upgraded, $company)->granted_via)->toBe('auto-upgrade');
$manualContact->delete();
$teamContact->delete();
expect(membershipOf($manual, $company)->role)->toBe('responsible')
->and(membershipOf($manual, $company)->granted_via)->toBe('manual')
->and(membershipOf($upgraded, $company)->role)->toBe('member')
->and(membershipOf($upgraded, $company)->granted_via)->toBe('manual');
});
test('the resync command removes stale automatic grants left by bulk updates', function () {
/** @var TestCase $this */
[$user, $company, $contact] = memberViaContact();
[$keeper, $keptCompany] = memberViaContact('bleibt@firma.test');
// Massen-Update am Model vorbei: kein Observer.
DB::table('contacts')->where('id', $contact->id)->update(['email' => 'anders@firma.test']);
expect(membershipOf($user, $company))->not->toBeNull();
$this->artisan('memberships:resync-auto')
->expectsOutputToContain('Automatische Rechte vorher 2, nachher 1')
->assertSuccessful();
expect(membershipOf($user, $company))->toBeNull()
->and(membershipOf($keeper, $keptCompany)?->granted_via)->toBe('auto');
});