auth/security: API-Firmenzuordnung nur für Mitglieder oder Allowlist, Google-Vorabübernahme geschlossen, gemeinsamer Login-Abschluss (Umsetzungsaudit 29.09.: R-01, K-04, K-07, K-08, B-10)
- R-01: ApiNewsroomAssignment hängt an eine bestehende Firma nur noch Owner, Mitglieder oder Nutzer aus NEWSROOM_API_ASSIGNMENT_USER_IDS; Pivot mit granted_via = api; Befehl company-memberships:api-assignments (Liste, --remove mit Vorschau) - K-04: OAuth-Verifikation eines offenen Kontos verwirft Passwort, remember_token und Sanctum-Tokens; SessionRevocation + Middleware EnsureSessionIsNotRevoked widerruft alte Sessions (Redis-tauglich) - K-08: LoginCompletion für Passwort, Magic Link und Google — last_login_at und Anmeldemethode auch nach 2FA, Landeziel über die Challenge, Google ohne erzwungenes remember, magic-login ohne guest für eigene Links - B-10: eigener Magic-Link-Zweck image_confirmation (Migration) - K-07: legalAcceptanceVersions #[Locked], Versionswechsel beim Absenden wird abgelehnt, Gate-Abfragen je Request zwischengespeichert Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
dfb0513e73
commit
1d417f87f7
29 changed files with 1287 additions and 73 deletions
|
|
@ -8,6 +8,7 @@ use App\Models\Company;
|
|||
use App\Models\CompanyEmailGroup;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\ContactAccessService;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
|
@ -18,7 +19,7 @@ use Tests\TestCase;
|
|||
* Firmen-E-Mail (Roadmap 1a.4). connektar legt je Meldung eine Firma an —
|
||||
* ohne die Zuordnung entstünde je Meldung ein neuer Newsroom.
|
||||
*/
|
||||
function assignmentApiUser(Portal $portal = Portal::Businessportal24): User
|
||||
function assignmentApiUser(Portal $portal = Portal::Businessportal24, bool $allowlisted = true): User
|
||||
{
|
||||
test()->seed(RolesAndPermissionsSeeder::class);
|
||||
config()->set('billing.enforce_booking', false);
|
||||
|
|
@ -27,6 +28,11 @@ function assignmentApiUser(Portal $portal = Portal::Businessportal24): User
|
|||
$user = User::factory()->create(['is_active' => true, 'portal' => $portal]);
|
||||
$user->forceFill(['registration_type' => 'apiuser'])->save();
|
||||
|
||||
// Zuordnung zu bestehenden Firmen nur für freigeschaltete Einlieferer
|
||||
// (Umsetzungsaudit 29.09.2026, R-01) — die Bestandstests laufen als
|
||||
// freigeschalteter Einlieferer (connektar).
|
||||
config()->set('newsroom.api_assignment.allowed_user_ids', $allowlisted ? [$user->id] : []);
|
||||
|
||||
Sanctum::actingAs($user, ['press-releases:read', 'press-releases:write', 'companies:read']);
|
||||
|
||||
return $user;
|
||||
|
|
@ -61,7 +67,7 @@ test('the same company under the same email is reused instead of created', funct
|
|||
|
||||
expect($id)->toBe($existing->id)
|
||||
->and(Company::withoutGlobalScopes()->where('email', 'like', '%muster.de')->count())->toBe(1)
|
||||
->and($existing->users()->whereKey($user->id)->wherePivot('role', 'member')->exists())->toBeTrue()
|
||||
->and($existing->users()->whereKey($user->id)->wherePivot('role', 'member')->wherePivot('granted_via', 'api')->exists())->toBeTrue()
|
||||
->and(ActivityEvent::query()->where('subject_id', $existing->id)->where('description', 'like', 'API-Firma%')->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
|
|
@ -162,3 +168,92 @@ test('without an email a company is created as before', function () {
|
|||
|
||||
expect(postApiCompany(['email' => null]))->not->toBe($existing->id);
|
||||
});
|
||||
|
||||
/*
|
||||
* R-01 (Umsetzungsaudit 29.09.2026): Firmenname und Kontaktadresse stehen
|
||||
* öffentlich im Newsroom. Ohne Freischaltung darf ein fremdes Token darüber
|
||||
* keine Mitgliedschaft (Schreibrecht auf alle Firmen-PMs) erlangen.
|
||||
*/
|
||||
test('a foreign token with matching email and name gets its own company and no membership', function () {
|
||||
/** @var TestCase $this */
|
||||
$user = assignmentApiUser(Portal::Presseecho, allowlisted: false);
|
||||
$existing = Company::factory()->create(['email' => 'info@muster.de', 'name' => 'Muster GmbH', 'portal' => 'businessportal24']);
|
||||
|
||||
$id = postApiCompany();
|
||||
|
||||
expect($id)->not->toBe($existing->id)
|
||||
->and($existing->users()->whereKey($user->id)->exists())->toBeFalse()
|
||||
->and($existing->fresh()->portal)->toBe(Portal::Businessportal24)
|
||||
->and(Company::withoutGlobalScopes()->find($id)->owner_user_id)->toBe($user->id)
|
||||
->and(ActivityEvent::query()->where('subject_id', $existing->id)->where('description', 'like', 'API-Firma%')->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('a user who already belongs to the company is reused without allowlist and keeps his role', function () {
|
||||
/** @var TestCase $this */
|
||||
$user = assignmentApiUser(allowlisted: false);
|
||||
$existing = Company::factory()->create(['email' => 'info@muster.de', 'name' => 'Muster GmbH', 'portal' => 'businessportal24']);
|
||||
$existing->users()->attach($user->id, ['role' => 'responsible', 'granted_via' => 'manual']);
|
||||
|
||||
expect(postApiCompany())->toBe($existing->id)
|
||||
->and($existing->users()->whereKey($user->id)->first()->pivot->role)->toBe('responsible')
|
||||
->and($existing->users()->whereKey($user->id)->first()->pivot->granted_via)->toBe('manual');
|
||||
});
|
||||
|
||||
test('the owner of the company is reused without allowlist and gets no extra pivot', function () {
|
||||
/** @var TestCase $this */
|
||||
$user = assignmentApiUser(allowlisted: false);
|
||||
$existing = Company::factory()->create(['email' => 'info@muster.de', 'name' => 'Muster GmbH', 'portal' => 'businessportal24', 'owner_user_id' => $user->id]);
|
||||
|
||||
expect(postApiCompany())->toBe($existing->id)
|
||||
->and($existing->users()->whereKey($user->id)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
test('the login sync never revokes an api membership', function () {
|
||||
/** @var TestCase $this */
|
||||
$user = assignmentApiUser();
|
||||
$existing = Company::factory()->create(['email' => 'info@muster.de', 'name' => 'Muster GmbH', 'portal' => 'businessportal24']);
|
||||
postApiCompany();
|
||||
|
||||
app(ContactAccessService::class)->syncMembershipsForEmail($user->fresh());
|
||||
|
||||
expect($existing->users()->whereKey($user->id)->wherePivot('granted_via', 'api')->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('the review command lists api assignments and removes foreign ones only with --remove', function () {
|
||||
/** @var TestCase $this */
|
||||
$allowed = assignmentApiUser();
|
||||
$existing = Company::factory()->create(['email' => 'info@muster.de', 'name' => 'Muster GmbH', 'portal' => 'businessportal24']);
|
||||
postApiCompany();
|
||||
|
||||
// Altfall vor dem Fix: ein fremdes Konto wurde per API-Zuordnung Mitglied.
|
||||
$stranger = User::factory()->create();
|
||||
$existing->users()->attach($stranger->id, ['role' => 'member', 'granted_via' => 'manual']);
|
||||
ActivityEvent::query()->create([
|
||||
'type' => 'correction', 'occurred_at' => now(), 'user_id' => $stranger->id, 'actor_label' => 'api',
|
||||
'subject_type' => $existing->getMorphClass(), 'subject_id' => $existing->id,
|
||||
'description' => 'API-Firma „Muster GmbH“ dem bestehenden Newsroom #'.$existing->id.' „Muster GmbH“ zugeordnet (info@muster.de)',
|
||||
'meta' => ['api_newsroom_assignment' => true],
|
||||
]);
|
||||
|
||||
// Mitglied schon vor der Zuordnung: bleibt unangetastet.
|
||||
$veteran = User::factory()->create();
|
||||
$existing->users()->attach($veteran->id, ['role' => 'member', 'granted_via' => 'manual', 'created_at' => now()->subMonth(), 'updated_at' => now()->subMonth()]);
|
||||
ActivityEvent::query()->create([
|
||||
'type' => 'correction', 'occurred_at' => now(), 'user_id' => $veteran->id, 'actor_label' => 'api',
|
||||
'subject_type' => $existing->getMorphClass(), 'subject_id' => $existing->id,
|
||||
'description' => 'API-Firma „Muster GmbH“ dem bestehenden Newsroom #'.$existing->id.' „Muster GmbH“ zugeordnet (info@muster.de)',
|
||||
'meta' => ['api_newsroom_assignment' => true],
|
||||
]);
|
||||
|
||||
$this->artisan('company-memberships:api-assignments')
|
||||
->expectsOutputToContain('2 über die API-Zuordnung entstandene Mitgliedschaft(en), davon 1 ohne Freischaltung')
|
||||
->assertSuccessful();
|
||||
|
||||
expect($existing->users()->whereKey($stranger->id)->exists())->toBeTrue();
|
||||
|
||||
$this->artisan('company-memberships:api-assignments', ['--remove' => true, '--force' => true])->assertSuccessful();
|
||||
|
||||
expect($existing->users()->whereKey($stranger->id)->exists())->toBeFalse()
|
||||
->and($existing->users()->whereKey($allowed->id)->exists())->toBeTrue()
|
||||
->and($existing->users()->whereKey($veteran->id)->exists())->toBeTrue();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,9 +2,12 @@
|
|||
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Laravel\Socialite\Contracts\Provider;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
use Laravel\Socialite\Two\User as SocialiteUser;
|
||||
use Livewire\Volt\Volt;
|
||||
use PragmaRX\Google2FA\Google2FA;
|
||||
use Tests\TestCase;
|
||||
|
||||
|
|
@ -123,3 +126,55 @@ test('google login hands an account with confirmed two-factor to the challenge (
|
|||
expect(session('login.id'))->toBe($user->id);
|
||||
expect($user->fresh()->last_login_at)->toBeNull();
|
||||
});
|
||||
|
||||
/*
|
||||
* K-04 (Umsetzungsaudit 29.09.2026): Vorab-Übernahme. Ein Angreifer
|
||||
* registriert die Adresse der echten Pressestelle mit eigenem Passwort; die
|
||||
* Pressestelle meldet sich später mit Google an. Danach darf weder das
|
||||
* Passwort noch der Remember-Cookie noch eine offene Session des Angreifers
|
||||
* das Konto erreichen.
|
||||
*/
|
||||
test('a pre-registered account is no longer reachable with the old password, token or session after google verifies it', function () {
|
||||
/** @var TestCase $this */
|
||||
$pending = User::factory()->unverified()->create([
|
||||
'email' => 'pressestelle@firma.test',
|
||||
'password' => Hash::make('attacker-secret'),
|
||||
'is_active' => false,
|
||||
'remember_token' => 'attacker-remember-token',
|
||||
]);
|
||||
$pending->createToken('attacker', ['press-releases:read']);
|
||||
|
||||
// Offene Session des Angreifers (vor der Google-Anmeldung entstanden).
|
||||
$this->actingAs($pending);
|
||||
$this->flushSession();
|
||||
|
||||
fakeGoogleUser('g-takeover-1', 'pressestelle@firma.test', 'Pressestelle');
|
||||
|
||||
Auth::logout();
|
||||
|
||||
$this->get(route('oauth.google.callback'))
|
||||
->assertRedirect(route('me.dashboard', absolute: false));
|
||||
|
||||
$pending->refresh();
|
||||
expect($pending->password)->toBeNull()
|
||||
->and($pending->remember_token)->not->toBe('attacker-remember-token')
|
||||
->and($pending->tokens()->count())->toBe(0);
|
||||
|
||||
// Die neue Google-Session bleibt gültig.
|
||||
$this->get(route('me.dashboard'))->assertOk();
|
||||
$this->assertAuthenticatedAs($pending);
|
||||
|
||||
// Die Session des Angreifers (ohne Anmeldezeitpunkt nach der Übernahme) nicht.
|
||||
Auth::logout();
|
||||
$this->flushSession();
|
||||
$this->actingAs($pending)->get(route('me.dashboard'))->assertRedirect(route('login'));
|
||||
$this->assertGuest();
|
||||
|
||||
// Und das alte Passwort führt nicht mehr hinein.
|
||||
Volt::test('auth.login')
|
||||
->set('email', 'pressestelle@firma.test')
|
||||
->set('password', 'attacker-secret')
|
||||
->call('login')
|
||||
->assertHasErrors(['email']);
|
||||
$this->assertGuest();
|
||||
});
|
||||
|
|
|
|||
165
tests/Feature/Auth/LoginCompletionTest.php
Normal file
165
tests/Feature/Auth/LoginCompletionTest.php
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\ActivityType;
|
||||
use App\Models\ActivityEvent;
|
||||
use App\Models\Company;
|
||||
use App\Models\MagicLink;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Laravel\Socialite\Contracts\Provider;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
use Laravel\Socialite\Two\User as SocialiteUser;
|
||||
use Livewire\Volt\Volt;
|
||||
use PragmaRX\Google2FA\Google2FA;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Einheitliche Login-Abschlüsse (Plan 1.8, Umsetzungsaudit 29.09.2026 K-08):
|
||||
* Passwort, Magic Link und Google setzen denselben Stand — auch nach der
|
||||
* 2FA-Challenge —, und ein eingeloggter Kunde kann seinen eigenen Link öffnen.
|
||||
*/
|
||||
beforeEach(function (): void {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
});
|
||||
|
||||
/**
|
||||
* @return array{user: User, secret: string|null, company: Company, pr: PressRelease}
|
||||
*/
|
||||
function loginCompletionCustomer(bool $twoFactor = false): array
|
||||
{
|
||||
$user = User::factory()->create(['is_active' => true]);
|
||||
$user->assignRole('customer');
|
||||
$secret = null;
|
||||
|
||||
if ($twoFactor) {
|
||||
$secret = app(Google2FA::class)->generateSecretKey();
|
||||
$user->forceFill(['two_factor_secret' => encrypt($secret), 'two_factor_confirmed_at' => now()])->save();
|
||||
}
|
||||
|
||||
$company = Company::factory()->presseecho()->create();
|
||||
$user->companies()->attach($company->id, ['role' => 'owner']);
|
||||
$pr = PressRelease::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'company_id' => $company->id,
|
||||
'portal' => $company->portal->value,
|
||||
'status' => 'draft',
|
||||
]);
|
||||
|
||||
return compact('user', 'secret', 'company', 'pr');
|
||||
}
|
||||
|
||||
function loginCompletionLink(User $user, string $token, array $payload = []): MagicLink
|
||||
{
|
||||
return MagicLink::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'token_hash' => hash('sha256', $token),
|
||||
'purpose' => 'login',
|
||||
'payload' => $payload ?: null,
|
||||
'expires_at' => now()->addMinutes(30),
|
||||
]);
|
||||
}
|
||||
|
||||
function loginCompletionMethod(User $user): ?string
|
||||
{
|
||||
return ActivityEvent::query()
|
||||
->where('type', ActivityType::UserLoggedIn->value)
|
||||
->where('user_id', $user->id)
|
||||
->latest('id')
|
||||
->first()?->meta['method'] ?? null;
|
||||
}
|
||||
|
||||
test('the password login records the last login also after the two-factor challenge', function () {
|
||||
/** @var TestCase $this */
|
||||
['user' => $user, 'secret' => $secret] = loginCompletionCustomer(twoFactor: true);
|
||||
|
||||
Volt::test('auth.login')
|
||||
->set('email', $user->email)
|
||||
->set('password', 'password')
|
||||
->call('login')
|
||||
->assertRedirect(route('two-factor.challenge'));
|
||||
|
||||
$this->post('/two-factor-challenge', ['code' => app(Google2FA::class)->getCurrentOtp($secret)])
|
||||
->assertRedirect(route('me.dashboard', absolute: false));
|
||||
|
||||
$this->assertAuthenticatedAs($user);
|
||||
expect($user->fresh()->last_login_at)->not->toBeNull()
|
||||
->and(loginCompletionMethod($user))->toBe('password');
|
||||
});
|
||||
|
||||
test('a magic link keeps its landing target and its method through the two-factor challenge', function () {
|
||||
/** @var TestCase $this */
|
||||
['user' => $user, 'secret' => $secret, 'company' => $company, 'pr' => $pr] = loginCompletionCustomer(twoFactor: true);
|
||||
loginCompletionLink($user, 'landing-2fa', ['company_id' => $company->id, 'press_release_id' => $pr->id]);
|
||||
|
||||
$this->get(route('magic-links.consume', ['token' => 'landing-2fa']))
|
||||
->assertRedirect(route('two-factor.challenge'));
|
||||
|
||||
$this->post('/two-factor-challenge', ['code' => app(Google2FA::class)->getCurrentOtp($secret)])
|
||||
->assertRedirect(route('me.press-releases.show', ['id' => $pr->id], absolute: false));
|
||||
|
||||
expect($user->fresh()->last_login_at)->not->toBeNull()
|
||||
->and(loginCompletionMethod($user))->toBe('magic-link');
|
||||
});
|
||||
|
||||
test('google no longer forces a remember cookie, also not through the two-factor challenge', function () {
|
||||
/** @var TestCase $this */
|
||||
['user' => $user, 'secret' => $secret] = loginCompletionCustomer(twoFactor: true);
|
||||
|
||||
$provider = Mockery::mock(Provider::class);
|
||||
$provider->shouldReceive('user')->andReturn((new SocialiteUser)->map(['id' => 'g-lc-1', 'email' => $user->email, 'name' => 'Kunde']));
|
||||
Socialite::shouldReceive('driver')->with('google')->andReturn($provider);
|
||||
|
||||
$this->get(route('oauth.google.callback'))->assertRedirect(route('two-factor.challenge'));
|
||||
expect(session('login.remember'))->toBeFalse();
|
||||
|
||||
$response = $this->post('/two-factor-challenge', ['code' => app(Google2FA::class)->getCurrentOtp($secret)]);
|
||||
|
||||
$response->assertRedirect(route('me.dashboard', absolute: false));
|
||||
expect(collect($response->headers->getCookies())->contains(fn ($cookie) => str_starts_with($cookie->getName(), 'remember_web_')))->toBeFalse()
|
||||
->and(loginCompletionMethod($user))->toBe('google')
|
||||
->and($user->fresh()->last_login_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('google without two-factor logs in without a remember cookie', function () {
|
||||
/** @var TestCase $this */
|
||||
['user' => $user] = loginCompletionCustomer();
|
||||
|
||||
$provider = Mockery::mock(Provider::class);
|
||||
$provider->shouldReceive('user')->andReturn((new SocialiteUser)->map(['id' => 'g-lc-2', 'email' => $user->email, 'name' => 'Kunde']));
|
||||
Socialite::shouldReceive('driver')->with('google')->andReturn($provider);
|
||||
|
||||
$response = $this->get(route('oauth.google.callback'));
|
||||
|
||||
$response->assertRedirect(route('me.dashboard', absolute: false));
|
||||
$this->assertAuthenticatedAs($user);
|
||||
expect(collect($response->headers->getCookies())->contains(fn ($cookie) => str_starts_with($cookie->getName(), 'remember_web_')))->toBeFalse();
|
||||
});
|
||||
|
||||
test('a logged-in customer opening his own magic link lands on its target', function () {
|
||||
/** @var TestCase $this */
|
||||
['user' => $user, 'company' => $company, 'pr' => $pr] = loginCompletionCustomer();
|
||||
$link = loginCompletionLink($user, 'own-link', ['company_id' => $company->id, 'press_release_id' => $pr->id]);
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('magic-links.consume', ['token' => 'own-link']))
|
||||
->assertRedirect(route('me.press-releases.show', ['id' => $pr->id], absolute: false));
|
||||
|
||||
$this->assertAuthenticatedAs($user);
|
||||
expect($link->fresh()->consumed_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('a logged-in user opening a link of another account keeps the previous behaviour', function () {
|
||||
/** @var TestCase $this */
|
||||
['user' => $user] = loginCompletionCustomer();
|
||||
['user' => $other] = loginCompletionCustomer();
|
||||
$link = loginCompletionLink($other, 'foreign-link');
|
||||
|
||||
$this->actingAs($user)
|
||||
->get(route('magic-links.consume', ['token' => 'foreign-link']))
|
||||
->assertRedirect(route('me.dashboard'));
|
||||
|
||||
$this->assertAuthenticatedAs($user);
|
||||
expect($link->fresh()->consumed_at)->toBeNull();
|
||||
});
|
||||
|
|
@ -10,8 +10,11 @@ use App\Models\User;
|
|||
use App\Services\Legal\LegalAcceptanceGate;
|
||||
use Database\Seeders\LegalDocumentSeeder;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Exceptions;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
|
||||
use Livewire\Volt\Volt as LivewireVolt;
|
||||
use Tests\TestCase;
|
||||
|
||||
|
|
@ -179,3 +182,102 @@ test('the api answers 428 with the required documents until they are accepted',
|
|||
|
||||
$this->getJson('/api/v1/legal/acceptances')->assertSuccessful()->assertJsonPath('gate_enabled', true)->assertJsonCount(2, 'accepted');
|
||||
});
|
||||
|
||||
/*
|
||||
* K-07 / B-13 (Umsetzungsaudit 29.09.2026): Die gezeigten Fassungen sind
|
||||
* gesperrt, ein Versionswechsel zwischen Prüfung und Speichern wird nicht
|
||||
* still übergangen, und der Rendering-Hook fragt je Request höchstens einmal
|
||||
* (bei ausgeschaltetem Gate gar nicht) und wirft nie beim Rendern.
|
||||
*/
|
||||
test('the shown versions cannot be changed from the browser', function () {
|
||||
/** @var TestCase $this */
|
||||
config()->set('legal.acceptance_gate', true);
|
||||
['owner' => $owner, 'pr' => $pr] = legalGateDraft();
|
||||
$this->actingAs($owner);
|
||||
|
||||
expect(fn () => LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
|
||||
->set('legalAcceptanceVersions', ['agb' => '9.9', 'richtlinien' => '9.9']))
|
||||
->toThrow(CannotUpdateLockedPropertyException::class);
|
||||
});
|
||||
|
||||
test('a version change between validation and saving is rejected instead of silently skipped', function () {
|
||||
/** @var TestCase $this */
|
||||
config()->set('legal.acceptance_gate', true);
|
||||
['owner' => $owner, 'pr' => $pr] = legalGateDraft();
|
||||
$this->actingAs($owner);
|
||||
|
||||
$current = app(LegalAcceptanceGate::class)->requiredFor($owner);
|
||||
$newer = LegalDocument::factory()->agb()->make(['version' => '0.7']);
|
||||
$submitCalls = null;
|
||||
|
||||
// Anzeige und Validierung sehen 0.6, beim Speichern gilt schon 0.7.
|
||||
$this->partialMock(LegalAcceptanceGate::class)
|
||||
->shouldReceive('requiredFor')
|
||||
->andReturnUsing(function () use (&$submitCalls, $current, $newer) {
|
||||
if ($submitCalls === null) {
|
||||
return $current;
|
||||
}
|
||||
|
||||
$submitCalls++;
|
||||
|
||||
return $submitCalls === 1 ? $current : collect([$newer]);
|
||||
});
|
||||
|
||||
$component = LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
|
||||
->set('aiTextDeclaration', 'none')
|
||||
->set('aiDeclarationConfirmed', true)
|
||||
->set('legalAcceptanceConfirmed', true);
|
||||
|
||||
$submitCalls = 0;
|
||||
|
||||
$component->call('submitForReview')
|
||||
->assertHasErrors(['legalAcceptanceConfirmed']);
|
||||
|
||||
expect(LegalDocumentAcceptance::query()->count())->toBe(0)
|
||||
->and($pr->fresh()->status)->toBe(PressReleaseStatus::Draft);
|
||||
});
|
||||
|
||||
test('with the gate off rendering the submit pages asks nothing about legal documents', function () {
|
||||
/** @var TestCase $this */
|
||||
['owner' => $owner, 'pr' => $pr] = legalGateDraft();
|
||||
$this->actingAs($owner);
|
||||
|
||||
DB::enableQueryLog();
|
||||
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])->assertOk();
|
||||
|
||||
expect(collect(DB::getQueryLog())->filter(fn (array $query): bool => str_contains($query['query'], 'legal_document_acceptances')))->toBeEmpty();
|
||||
});
|
||||
|
||||
test('with the gate on a render asks once per request, not once per modal', function () {
|
||||
/** @var TestCase $this */
|
||||
config()->set('legal.acceptance_gate', true);
|
||||
['owner' => $owner, 'pr' => $pr] = legalGateDraft();
|
||||
$this->actingAs($owner);
|
||||
|
||||
DB::enableQueryLog();
|
||||
LivewireVolt::test('customer.press-releases.index')->assertOk();
|
||||
|
||||
// Eine Abfrage je Dokumenttyp (AGB, Richtlinien) — nicht zusätzlich je Modal.
|
||||
expect(collect(DB::getQueryLog())->filter(fn (array $query): bool => str_contains($query['query'], 'legal_document_acceptances')))->toHaveCount(2);
|
||||
});
|
||||
|
||||
test('a missing required document fails the submit, not the rendering of the page', function () {
|
||||
/** @var TestCase $this */
|
||||
config()->set('legal.acceptance_gate', true);
|
||||
['owner' => $owner, 'pr' => $pr] = legalGateDraft();
|
||||
$this->actingAs($owner);
|
||||
|
||||
LegalDocument::query()->where('type', 'richtlinien')->delete();
|
||||
Exceptions::fake();
|
||||
|
||||
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
|
||||
->assertOk()
|
||||
->set('aiTextDeclaration', 'none')
|
||||
->set('aiDeclarationConfirmed', true)
|
||||
->set('legalAcceptanceConfirmed', true)
|
||||
->call('submitForReview')
|
||||
->assertHasErrors(['legalAcceptanceConfirmed']);
|
||||
|
||||
expect($pr->fresh()->status)->toBe(PressReleaseStatus::Draft);
|
||||
Exceptions::assertReported(RuntimeException::class);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue