- 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>
283 lines
12 KiB
PHP
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);
|
|
});
|