presseportale/tests/Feature/Legal/LegalAcceptanceGateTest.php
Kevin Adametz 1ccb4a8479 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>
2026-10-02 13:09:00 +00:00

283 lines
12 KiB
PHP

<?php
use App\Enums\PressReleaseStatus;
use App\Models\Category;
use App\Models\Company;
use App\Models\LegalDocument;
use App\Models\LegalDocumentAcceptance;
use App\Models\PressRelease;
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;
/**
* Zustimmungsgate (Bildkennzeichnung Block G, Decision-Update E12, O2).
*/
beforeEach(function (): void {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$this->seed(LegalDocumentSeeder::class);
Mail::fake();
config()->set('billing.enforce_booking', false);
});
/**
* @return array{owner: User, pr: PressRelease}
*/
function legalGateDraft(): array
{
$owner = User::factory()->create(['is_active' => true]);
$owner->assignRole('customer');
$company = Company::factory()->presseecho()->create();
$owner->companies()->attach($company->id, ['role' => 'owner']);
$pr = PressRelease::factory()->create([
'user_id' => $owner->id,
'company_id' => $company->id,
'category_id' => Category::factory()->create()->id,
'portal' => $company->portal->value,
'status' => 'draft',
]);
return compact('owner', 'pr');
}
test('with the gate off nothing is required and submitting works as before', function () {
/** @var TestCase $this */
['owner' => $owner, 'pr' => $pr] = legalGateDraft();
$this->actingAs($owner);
expect(app(LegalAcceptanceGate::class)->requiredFor($owner))->toBeEmpty();
LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
->assertDontSee('Neue Fassung unserer Bedingungen')
->set('aiTextDeclaration', 'none')
->set('aiDeclarationConfirmed', true)
->call('submitForReview')
->assertHasNoErrors();
expect($pr->fresh()->status)->not->toBe(PressReleaseStatus::Draft)
->and(LegalDocumentAcceptance::query()->count())->toBe(0);
});
test('with the gate on the submit requires an active acceptance of agb and guidelines and records both', function () {
/** @var TestCase $this */
config()->set('legal.acceptance_gate', true);
['owner' => $owner, 'pr' => $pr] = legalGateDraft();
$this->actingAs($owner);
expect(app(LegalAcceptanceGate::class)->requiredFor($owner)->pluck('type')->all())->toBe(['agb', 'richtlinien']);
$component = LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
->assertSee('Neue Fassung unserer Bedingungen')
->assertSee('Version 0.6')
->set('aiTextDeclaration', 'none')
->set('aiDeclarationConfirmed', true)
->call('submitForReview')
->assertHasErrors(['legalAcceptanceConfirmed']);
expect($pr->fresh()->status)->toBe(PressReleaseStatus::Draft);
$component->set('legalAcceptanceConfirmed', true)->call('submitForReview')->assertHasNoErrors();
// Der synchrone Prüf-Job kann die Meldung direkt veröffentlichen — entscheidend ist: nicht mehr Entwurf.
expect($pr->fresh()->status)->not->toBe(PressReleaseStatus::Draft)
->and(LegalDocumentAcceptance::query()->where('user_id', $owner->id)->pluck('type')->sort()->values()->all())->toBe(['agb', 'richtlinien'])
->and(LegalDocumentAcceptance::query()->first()->context)->toBe('submit')
->and(app(LegalAcceptanceGate::class)->requiredFor($owner))->toBeEmpty();
// Neue AGB-Fassung: das Gate fordert genau diese eine Fassung erneut.
LegalDocument::factory()->agb()->create(['version' => '0.7', 'effective_from' => now()->subDay()->toDateString()]);
expect(app(LegalAcceptanceGate::class)->requiredFor($owner)->pluck('version')->all())->toBe(['0.7']);
});
test('a confirmation is bound to the versions that were shown, a newer version must be read and accepted again', function () {
/** @var TestCase $this */
config()->set('legal.acceptance_gate', true);
['owner' => $owner, 'pr' => $pr] = legalGateDraft();
$this->actingAs($owner);
$component = LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id])
->assertSee('Version 0.6')
->set('aiTextDeclaration', 'none')
->set('aiDeclarationConfirmed', true)
->set('legalAcceptanceConfirmed', true);
expect($component->get('legalAcceptanceVersions')['agb'])->toBe('0.6');
// Zwischen Anzeige und Absenden wird eine neue AGB-Fassung wirksam.
LegalDocument::factory()->agb()->create(['version' => '0.7', 'effective_from' => now()->subDay()->toDateString()]);
$component->call('submitForReview')->assertHasErrors(['legalAcceptanceConfirmed']);
expect(LegalDocumentAcceptance::query()->count())->toBe(0)
->and($pr->fresh()->status)->toBe(PressReleaseStatus::Draft)
->and($component->get('legalAcceptanceConfirmed'))->toBeFalse()
->and($component->get('legalAcceptanceVersions')['agb'])->toBe('0.7');
// Neu gelesen und bestätigt: jetzt wird genau 0.7 protokolliert.
$component->assertSee('Version 0.7')
->set('aiDeclarationConfirmed', true)
->set('legalAcceptanceConfirmed', true)
->call('submitForReview')
->assertHasNoErrors();
expect(LegalDocumentAcceptance::query()->where('type', 'agb')->pluck('version')->all())->toBe(['0.7'])
->and($pr->fresh()->status)->not->toBe(PressReleaseStatus::Draft);
});
test('a missing required document is a configuration error while the gate is on, not a smaller gate', function () {
/** @var TestCase $this */
config()->set('legal.acceptance_gate', true);
['owner' => $owner] = legalGateDraft();
LegalDocument::query()->where('type', 'richtlinien')->delete();
expect(fn () => app(LegalAcceptanceGate::class)->requiredFor($owner))->toThrow(RuntimeException::class, 'richtlinien');
});
test('acceptances are insert-only', function () {
config()->set('legal.acceptance_gate', true);
['owner' => $owner] = legalGateDraft();
$gate = app(LegalAcceptanceGate::class);
$rows = $gate->accept($owner, $gate->requiredFor($owner), 'gate', '203.0.113.5', 'Pest');
expect($rows)->toHaveCount(2)
->and($rows[0]->ip_hmac)->toStartWith('v1:')
->and(fn () => $rows[0]->delete())->toThrow(LogicException::class)
->and($gate->accept($owner, $gate->requiredFor($owner), 'gate'))->toBe([]);
});
test('the api answers 428 with the required documents until they are accepted', function () {
/** @var TestCase $this */
config()->set('legal.acceptance_gate', true);
['owner' => $owner, 'pr' => $pr] = legalGateDraft();
Sanctum::actingAs($owner, ['press-releases:write', 'press-releases:read']);
$this->postJson("/api/v1/press-releases/{$pr->id}/submit", ['ai_text_declaration' => 'none'])
->assertStatus(428)
->assertJsonPath('required_documents.0.type', 'agb')
->assertJsonPath('required_documents.1.version', '0.2');
$this->postJson('/api/v1/legal/acceptances', ['documents' => [['type' => 'agb', 'version' => '0.5']]])
->assertUnprocessable();
$this->postJson('/api/v1/legal/acceptances', ['documents' => [
['type' => 'agb', 'version' => '0.6'],
['type' => 'richtlinien', 'version' => '0.2'],
]])->assertCreated()->assertJsonPath('required', []);
expect(LegalDocumentAcceptance::query()->where('context', 'api')->count())->toBe(2);
$this->postJson("/api/v1/press-releases/{$pr->id}/submit", ['ai_text_declaration' => 'none'])->assertSuccessful();
$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);
});