tests/legal: Testdatenbank-Guard vor den DB-Traits, Flags in phpunit.xml gepinnt, Zustimmung an gezeigte Fassung gebunden (Umsetzungsaudit 25.09.: N22, N06, 5.3)

- Tests\TestCase prüft die Standardverbindung in setUpTraits(), also vor
  RefreshDatabase — bisher erst nach dem Refresh (N22). Eigener Test.
- phpunit.xml pinnt IMAGE_RIGHTS_V2, IMAGE_PRECHECK(_STAGE) und
  LEGAL_ACCEPTANCE_GATE auf die Vorgaben aus .env.example; die Suite hing
  sonst an der lokalen .env (Audit Abschnitt 6).
- Zustimmungsgate: die Livewire-Komponenten tragen die beim Rendern gezeigten
  Fassungen (Trait-Hook rendering); weicht die aktuelle Fassung beim Absenden
  ab, wird die Annahme abgelehnt und der neue Wortlaut gezeigt. Ein fehlendes
  Pflichtdokument ist bei aktivem Gate ein Konfigurationsfehler (N06).
- Audit vom 25.09., Decision-Update vom 22.09. und Plan Fassung 2 versioniert;
  die Antwortkopie „…-211.md" entfernt; U10-Formulierung im Plan präzisiert.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-25 13:35:14 +00:00
parent aaeee24412
commit 34fa342c7f
10 changed files with 1436 additions and 10 deletions

View file

@ -7,6 +7,7 @@ use App\Models\LegalDocumentAcceptance;
use App\Services\Legal\LegalAcceptanceGate;
use Illuminate\Support\Carbon;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
/**
* KI-Kennzeichnung im Submit-Flow (Art. 50 KI-VO, Decision-Update 08.07.2026).
@ -30,6 +31,26 @@ trait InteractsWithAiDeclaration
*/
public bool $legalAcceptanceConfirmed = false;
/**
* Fassungen (type => version), die beim letzten Rendern im Submit-Modal
* standen. Der Schalter bestätigt genau diese; wechselt zwischen Anzeige
* und Absenden die Fassung, wird die Annahme abgelehnt und der neue
* Wortlaut gezeigt (Umsetzungsaudit 25.09.2026, N06). Livewire signiert
* den Zustand, der Browser kann die Werte nicht verändern.
*
* @var array<string, string>
*/
public array $legalAcceptanceVersions = [];
/**
* Livewire-Trait-Hook: läuft nach der Aktion, unmittelbar vor dem
* Rendern — die Property trägt danach exakt die gezeigten Fassungen.
*/
public function renderingInteractsWithAiDeclaration(): void
{
$this->legalAcceptanceVersions = LegalAcceptanceGate::versionsOf(app(LegalAcceptanceGate::class)->requiredFor(auth()->user()));
}
/**
* Pflichtprüfung vor dem Einreichen — Fehler erscheinen direkt im
* Submit-Modal. Entwurf-Speichern bleibt frei von dieser Pflicht.
@ -45,12 +66,22 @@ trait InteractsWithAiDeclaration
'aiDeclarationConfirmed.accepted' => __('Bitte bestätigen, dass die Angaben zum KI-Einsatz vollständig und wahrheitsgemäß sind.'),
];
if (app(LegalAcceptanceGate::class)->requiredFor(auth()->user())->isNotEmpty()) {
$required = app(LegalAcceptanceGate::class)->requiredFor(auth()->user());
if ($required->isNotEmpty()) {
$rules['legalAcceptanceConfirmed'] = ['accepted'];
$messages['legalAcceptanceConfirmed.accepted'] = __('Bitte nehmen Sie die aktuelle Fassung der AGB und der Veröffentlichungsrichtlinien an.');
}
$this->validate($rules, $messages);
if ($required->isNotEmpty() && LegalAcceptanceGate::versionsOf($required) !== $this->legalAcceptanceVersions) {
$this->legalAcceptanceConfirmed = false;
throw ValidationException::withMessages([
'legalAcceptanceConfirmed' => __('Die Bedingungen wurden zwischenzeitlich aktualisiert. Bitte lesen Sie die neue Fassung und nehmen Sie sie erneut an.'),
]);
}
}
/**
@ -62,7 +93,7 @@ trait InteractsWithAiDeclaration
$gate = app(LegalAcceptanceGate::class);
$required = $gate->requiredFor(auth()->user());
if ($required->isEmpty() || ! $this->legalAcceptanceConfirmed) {
if ($required->isEmpty() || ! $this->legalAcceptanceConfirmed || LegalAcceptanceGate::versionsOf($required) !== $this->legalAcceptanceVersions) {
return;
}

View file

@ -8,6 +8,7 @@ use App\Models\LegalDocumentAcceptance;
use App\Models\User;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use RuntimeException;
/**
* Zustimmungsgate vor jeder Einreichung (Bildkennzeichnung Block G,
@ -41,12 +42,37 @@ class LegalAcceptanceGate
}
return collect(self::TYPES)
->map(fn (string $type): ?LegalDocument => LegalDocument::current($type))
->filter()
->map(function (string $type): LegalDocument {
// Ein fehlendes Pflichtdokument ist ein Konfigurationsfehler und
// darf das Gate nicht still verkleinern (Umsetzungsaudit
// 25.09.2026, N06).
return LegalDocument::current($type)
?? throw new RuntimeException(sprintf('Zustimmungsgate: kein gültiges Dokument vom Typ „%s“ hinterlegt (legal_documents).', $type));
})
->reject(fn (LegalDocument $document): bool => $this->hasAccepted($user, $document))
->values();
}
/**
* Fassungen je Typ, wie sie dem Nutzer gerade gezeigt werden — die
* Bestätigung im Browser wird an genau diese Kennungen gebunden.
*
* @param iterable<int, LegalDocument> $documents
* @return array<string, string> type => version
*/
public static function versionsOf(iterable $documents): array
{
$versions = [];
foreach ($documents as $document) {
$versions[$document->type] = $document->version;
}
ksort($versions);
return $versions;
}
public function hasAccepted(User $user, LegalDocument $document): bool
{
return LegalDocumentAcceptance::query()