Auf Live meldete memberships:resync-auto nach v1.4.0 „vorher 19607, nachher 9": syncMembershipsForEmail() entzog Konten ohne email_verified_at alle automatischen Rechte, und 19.597 davon gehören Bestandskonten aus dem Altsystem. Jetzt wird nur entzogen, was keine E-Mail-Grundlage mehr hat — unabhängig vom Kontostatus (S-03 bleibt wirksam); neue Rechte weiterhin nur für aktive, verifizierte Konten (F01). memberships:resync-auto läuft in einer Transaktion, kennt --dry-run und rollt zurück, wenn mehr als 5 % (mindestens 50) der Rechte wegfallen würden (--force zum Erzwingen). Probelauf gegen die Live-Kopie: 19.607 → 19.316. Wiederherstellung: storage/app/private/restore-company-user-auto-2026-10-07.sql (nicht versioniert, INSERT IGNORE, idempotent). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
203 lines
8.7 KiB
PHP
203 lines
8.7 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');
|
|
});
|
|
|
|
/*
|
|
* Regression v1.4.1: Bestandskonten aus dem Altsystem haben kein
|
|
* `email_verified_at`. Der Abgleich am 07.10.2026 entzog ihnen pauschal alle
|
|
* automatischen Rechte (19.598 von 19.607), obwohl die Grundlage bestand.
|
|
*/
|
|
test('legacy accounts without verified email keep automatic access whose basis still exists', function () {
|
|
/** @var TestCase $this */
|
|
$legacy = User::factory()->unverified()->create(['email' => 'altkunde@firma.test', 'is_active' => true]);
|
|
$company = Company::factory()->presseecho()->create();
|
|
$contact = Contact::factory()->for($company)->create(['email' => 'altkunde@firma.test', 'portal' => $company->portal->value]);
|
|
$legacy->companies()->attach($company->id, ['role' => 'responsible', 'granted_via' => 'auto']);
|
|
|
|
$this->artisan('memberships:resync-auto')->assertSuccessful();
|
|
app(ContactAccessService::class)->syncMembershipsForEmail($legacy);
|
|
|
|
expect(membershipOf($legacy, $company)?->granted_via)->toBe('auto');
|
|
|
|
// Fällt die Grundlage weg, endet das Recht auch für sie (S-03).
|
|
$contact->delete();
|
|
|
|
expect(membershipOf($legacy, $company))->toBeNull();
|
|
});
|
|
|
|
test('inactive or unverified accounts do not receive new automatic access', function (array $attributes) {
|
|
$user = User::factory()->create(['email' => 'neu-ohne-nachweis@firma.test', ...$attributes]);
|
|
$company = Company::factory()->presseecho()->create();
|
|
Contact::factory()->for($company)->create(['email' => 'neu-ohne-nachweis@firma.test', 'portal' => $company->portal->value]);
|
|
|
|
app(ContactAccessService::class)->syncMembershipsForEmail($user);
|
|
|
|
expect(membershipOf($user, $company))->toBeNull();
|
|
})->with([
|
|
'unverified' => [['email_verified_at' => null]],
|
|
'inactive' => [['is_active' => false]],
|
|
]);
|
|
|
|
test('the resync command rolls back a suspicious mass revocation unless forced', function () {
|
|
/** @var TestCase $this */
|
|
$company = Company::factory()->presseecho()->create();
|
|
$users = User::factory()->count(60)->create();
|
|
|
|
foreach ($users as $user) {
|
|
$user->companies()->attach($company->id, ['role' => 'responsible', 'granted_via' => 'auto']);
|
|
}
|
|
|
|
$count = fn (): int => DB::table('company_user')->where('granted_via', 'auto')->count();
|
|
|
|
$this->artisan('memberships:resync-auto', ['--dry-run' => true])
|
|
->expectsOutputToContain('vorher 60, nachher 0. Probelauf')
|
|
->assertSuccessful();
|
|
expect($count())->toBe(60);
|
|
|
|
$this->artisan('memberships:resync-auto')
|
|
->expectsOutputToContain('zurückgerollt')
|
|
->assertFailed();
|
|
expect($count())->toBe(60);
|
|
|
|
$this->artisan('memberships:resync-auto', ['--force' => true])->assertSuccessful();
|
|
expect($count())->toBe(0);
|
|
});
|