presseportale/tests/Feature/Legal/AnonymizationServiceTest.php
Kevin Adametz b4b2e013d6 recht: Meldeverfahren v2 Phasen 1-4 — Re-Sync-Sperre, Anonymisierung, Magic-Link-Intake, Self-Service-Korrektur
Phase 1: sync_locked_at auf press_releases (Importer überspringt gesperrte
PMs auch bei --force), LegalRequest um Verifikation/Ausführung erweitert,
verschlüsselte Vorher-Snapshots mit Löschfrist (snapshots:purge, täglich).

Phase 2: AnonymizationService (Unicode-Wortgrenzen-Schwärzung, Pflicht-Diff,
transaktionale Ausführung mit Snapshot, Kontakt-Detach, Bild-Löschung,
Sitemap-Invalidierung, Verlauf ohne Klartext-Altwerte); Admin-Queue kann
DSGVO-Anfragen anonymisieren oder depublizieren, inkl. Snapshot-Restore.

Phase 3: Deflektions-Trichter „PM melden/ändern" (melden/pm) mit Kosten-
Tabelle vorab; Magic-Link geht nur an die hinterlegte Pressekontaktadresse
(neuer purpose press_release_report), atomare Verifikation, Anliegen-Auswahl
erzeugt verifizierte Queue-Anfragen; toter-Kanal-Fallback; Owner-Einstieg.

Phase 4: Panel-Abschnitt „Änderung & Korrektur" — Levenshtein-Gate (5 %,
Mini-Edit-Ausnahme ≤ 2 Zeichen) stuft klein/groß ein; kleine Korrektur
kostenlos sofort live (Snapshot, Sperre, Verlauf, Re-Score), größere als
Change-Anfrage (Interim bis 10-€-Checkout, Phase 7).

Entwicklungsplan mit Entscheidungen E1-E8 und offenen Punkten in
docs/Decision-Update; 41 Pest-Tests (tests/Feature/Legal) grün.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 15:56:34 +00:00

130 lines
5.2 KiB
PHP

<?php
use App\Enums\ActivityType;
use App\Enums\LegalRequestType;
use App\Models\ActivityEvent;
use App\Models\Contact;
use App\Models\LegalRequest;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Models\PressReleaseRedactionSnapshot;
use App\Services\PressRelease\AnonymizationService;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
test('redaction replaces terms only at word boundaries, longest variant first', function () {
/** @var TestCase $this */
$pressRelease = PressRelease::factory()->published()->create([
'title' => 'Max Mustermann übernimmt die Leitung',
'text' => '<p>Max ist maximal motiviert. Herr Mustermann bleibt. Mustermanns Rolle ändert sich.</p>',
]);
app(AnonymizationService::class)->execute(
$pressRelease,
['Max Mustermann', 'Max', 'Mustermann'],
'[entfernt]',
detachContacts: false,
);
$pressRelease->refresh();
expect($pressRelease->title)->toBe('[entfernt] übernimmt die Leitung')
->and($pressRelease->text)->toContain('maximal') // "Max" zerlegt "maximal" nicht
->and($pressRelease->text)->toContain('Mustermanns') // Genitiv nur über explizite Variante
->and($pressRelease->text)->toContain('[entfernt] ist maximal')
->and($pressRelease->text)->toContain('Herr [entfernt] bleibt')
->and($pressRelease->isSyncLocked())->toBeTrue();
});
test('preview reports hits per field without changing the press release', function () {
/** @var TestCase $this */
$pressRelease = PressRelease::factory()->published()->create([
'title' => 'Mariana Gleue informiert',
'text' => '<p>Kontakt: Mariana Gleue, Telefon 0123.</p>',
]);
$preview = app(AnonymizationService::class)->preview($pressRelease, ['Mariana Gleue']);
expect($preview['total_hits'])->toBe(2)
->and($preview['fields']['title']['hits'])->toBe(1)
->and($preview['fields']['text']['after'])->toContain('[entfernt]')
->and($pressRelease->fresh()->title)->toBe('Mariana Gleue informiert')
->and($pressRelease->fresh()->isSyncLocked())->toBeFalse();
});
test('execute snapshots encrypted, detaches contacts and logs without cleartext', function () {
/** @var TestCase $this */
$pressRelease = PressRelease::factory()->published()->create([
'title' => 'Matvey Teplitsky spricht für die Firma',
]);
$contact = Contact::factory()->create();
$pressRelease->contacts()->attach($contact->id);
$legalRequest = LegalRequest::factory()
->type(LegalRequestType::Dsgvo)
->create(['press_release_id' => $pressRelease->id]);
$snapshot = app(AnonymizationService::class)->execute(
$pressRelease,
['Matvey Teplitsky'],
'der Sprecher',
legalRequest: $legalRequest,
);
$pressRelease->refresh();
expect($pressRelease->title)->toBe('der Sprecher spricht für die Firma')
->and($pressRelease->contacts()->count())->toBe(0)
->and($snapshot->legal_request_id)->toBe($legalRequest->id)
->and($snapshot->encrypted_before['fields']['title'])->toContain('Matvey Teplitsky')
->and($snapshot->encrypted_before['contact_ids'])->toBe([$contact->id]);
// Klartext weder im DB-Rohwert des Snapshots noch im Verlaufs-Eintrag.
$raw = DB::table('press_release_redaction_snapshots')->where('id', $snapshot->id)->value('encrypted_before');
expect($raw)->not->toContain('Teplitsky');
$event = ActivityEvent::query()->where('type', ActivityType::Anonymization->value)->sole();
expect($event->description)->not->toContain('Teplitsky')
->and($event->meta['snapshot_id'])->toBe($snapshot->id);
});
test('selected images are soft-deleted and their public files removed', function () {
/** @var TestCase $this */
Storage::fake('public');
Storage::disk('public')->put('pr/foto.jpg', 'bild');
Storage::disk('public')->put('pr/foto-thumb.jpg', 'thumb');
$pressRelease = PressRelease::factory()->published()->create();
$image = PressReleaseImage::query()->create([
'press_release_id' => $pressRelease->id,
'disk' => 'public',
'path' => 'pr/foto.jpg',
'variants' => ['thumb' => 'pr/foto-thumb.jpg'],
'title' => 'Pressefoto mit Klarname',
]);
app(AnonymizationService::class)->execute(
$pressRelease,
[],
detachContacts: false,
removeImageIds: [$image->id],
);
expect(PressReleaseImage::query()->find($image->id))->toBeNull()
->and(PressReleaseImage::withTrashed()->find($image->id))->not->toBeNull();
Storage::disk('public')->assertMissing('pr/foto.jpg');
Storage::disk('public')->assertMissing('pr/foto-thumb.jpg');
});
test('the purge command removes only snapshots past their retention date', function () {
/** @var TestCase $this */
$due = PressReleaseRedactionSnapshot::factory()->dueForPurge()->create();
$kept = PressReleaseRedactionSnapshot::factory()->create();
$this->artisan('snapshots:purge')->assertSuccessful();
expect(PressReleaseRedactionSnapshot::query()->find($due->id))->toBeNull()
->and(PressReleaseRedactionSnapshot::query()->find($kept->id))->not->toBeNull();
});