audit: Nacharbeit Umsetzungsaudit 25.09.2026 — Bildfreigabe, Beweisarchiv, KI-Atomarität, Berichte (Block 8, N01–N05, N07, N08, N10–N21, 5.1, 5.2)
Bilder (N01, N05, N07, N08): gemeinsamer Freigabezustand PressReleaseImage::isPubliclyDeliverable() für Seiten, OG und Medienroute (Bestand bleibt sichtbar, v2 nur mit Bestätigung, Admin-Upload bis zum Kundenklick nicht öffentlich); eigene Ability confirmImageRights nur für den Kontoinhaber, ohne Super-Admin-Kurzschluss, Identität und ausstehende Bestätigung auch im Service; Status-Regel unter der PM-Sperre; Cache-Flush nach dem Commit außerhalb der Dateibereinigung; Snapshot nach Bestätigung an veröffentlichter PM; Bestätigungstexte an die gezeigte Kennung gebunden. Beweisarchiv (N02, N10, N11, N12, N13, 5.1): Tombstone unter Zeilensperre mit erneuter Hold-/Fristprüfung; Dateioperationen ausgewertet, Commands mit FAILURE; record() über .tmp + move; Marker archive_pending_at mit archive:retry-pending und archive:sweep-orphans; Anonymisierung entfernt Bilder über den Remover mit Antragsbezug; Draft-Purge räumt Medien nie veröffentlichter abgelehnter Einreichungen; App-Kopien fallen mit dem Archiv-Tombstone; label_corrected nur für veröffentlichte Bilder; archive_entries.legal_request_id; FileDeletion-Helfer; Systemstatus-Checks. KI und Hintergrund (N03, N04, N14, N20, N21): Ergebnis-UPDATE und Audit in einer Transaktion; erwarteter Hash bis in reject()/holdForManualReview(); Ablaufmail per beanspruchtem Marker in einer Transaktion; strikte Schemaprüfung der Vision-Antwort; Fortschrittsmarke ungecacht. Berichte und Canonical (N15–N19, 5.2): lückenlose Alterskohorten; Feed-Identität mit Portal und Sprache; Sitemap-Rolle in der Kundenansicht; Indexverteilung auf einer Grundgesamtheit; Kopien nach Wegfall der Erstquelle neu bewertet; Kategorie-Admin leert Seiten-Caches; Dry-Run zählt alle Tabellen. Gesamtsuite: 2.122 Tests grün, einer übersprungen; Larastan und Pint sauber. Zwei Migrationen (nullable Spalten, INSTANT). N09 offen. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
34fa342c7f
commit
2a2215b3c3
66 changed files with 3092 additions and 368 deletions
|
|
@ -1,26 +1,35 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\ActivityType;
|
||||
use App\Enums\ArchiveEvent;
|
||||
use App\Enums\ArchiveReasonCategory;
|
||||
use App\Enums\ConfirmationChannel;
|
||||
use App\Enums\LegalRequestType;
|
||||
use App\Enums\Portal;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\ActivityEvent;
|
||||
use App\Models\ArchiveAccessLog;
|
||||
use App\Models\ArchiveEntry;
|
||||
use App\Models\Category;
|
||||
use App\Models\Company;
|
||||
use App\Models\ImageDeclarationCorrection;
|
||||
use App\Models\ImageRightsConfirmation;
|
||||
use App\Models\LegalRequest;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseImage;
|
||||
use App\Models\PressReleaseRedactionSnapshot;
|
||||
use App\Models\User;
|
||||
use App\Services\Admin\AdminSystemHealth;
|
||||
use App\Services\Archive\EvidenceArchive;
|
||||
use App\Services\PressRelease\AnonymizationService;
|
||||
use App\Services\PressRelease\Images\ImageCorrectionService;
|
||||
use App\Services\PressRelease\Images\PressReleaseImageRemover;
|
||||
use App\Services\PressRelease\PressReleaseService;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\Support\FailingDisk;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
|
|
@ -312,3 +321,252 @@ test('the admin archive page lists entries and downloads only with a purpose', f
|
|||
$customer->assignRole('customer');
|
||||
$this->actingAs($customer)->get('https://pressekonto.test/admin/archive')->assertForbidden();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Umsetzungsaudit 25.09.2026: N02, N10, N11, 5.1 a/c/d
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('a legal hold set after the purge selected the entry still protects the payload', function () {
|
||||
/** @var TestCase $this */
|
||||
$admin = archiveAdmin();
|
||||
$pr = PressRelease::factory()->create();
|
||||
$archive = app(EvidenceArchive::class);
|
||||
|
||||
$first = $archive->record(ArchiveEvent::ImageRemoved, $pr, $pr->id, ArchiveReasonCategory::CustomerRequest, 'customer', null, ['x' => 1]);
|
||||
$second = $archive->record(ArchiveEvent::ImageRemoved, $pr, $pr->id, ArchiveReasonCategory::CustomerRequest, 'customer', null, ['x' => 2]);
|
||||
|
||||
$this->travelTo(now()->endOfYear()->addYears(3)->addDay());
|
||||
|
||||
// Beide Einträge sind im selben Chunk geladen; während der erste gelöscht
|
||||
// wird, setzt ein Admin den Hold auf den zweiten (alte Instanz im Purge).
|
||||
FailingDisk::install('archive', [
|
||||
'exists' => function (string $path, callable $real) use ($second): bool {
|
||||
DB::table('archive_entries')->where('id', $second->id)->update(['legal_hold' => true]);
|
||||
|
||||
return $real();
|
||||
},
|
||||
]);
|
||||
|
||||
$result = $archive->purgeDue();
|
||||
|
||||
expect($result)->toBe(['due' => 2, 'purged' => 1, 'failed' => 0])
|
||||
->and($first->fresh()->isTombstoned())->toBeTrue()
|
||||
->and($second->fresh()->isTombstoned())->toBeFalse()
|
||||
->and(Storage::disk('archive')->exists($second->payload_path))->toBeTrue();
|
||||
|
||||
FailingDisk::restore('archive');
|
||||
|
||||
// Hold auf einem Tombstone wird abgelehnt statt still gespeichert.
|
||||
expect(fn () => $archive->setLegalHold($first->fresh(), $admin, 'zu spät'))->toThrow(RuntimeException::class);
|
||||
expect($first->fresh()->legal_hold)->toBeFalse();
|
||||
});
|
||||
|
||||
test('a failed payload deletion leaves the entry active, fails the command and is retried on the next run', function () {
|
||||
/** @var TestCase $this */
|
||||
$pr = PressRelease::factory()->create();
|
||||
$archive = app(EvidenceArchive::class);
|
||||
$due = $archive->record(ArchiveEvent::ImageRemoved, $pr, $pr->id, ArchiveReasonCategory::CustomerRequest, 'customer', null, ['x' => 1]);
|
||||
|
||||
$this->travelTo(now()->endOfYear()->addYears(3)->addDay());
|
||||
FailingDisk::install('archive', ['delete' => fn () => false]);
|
||||
|
||||
$this->artisan('archive:purge')->expectsOutputToContain('fehlgeschlagen: 1')->assertFailed();
|
||||
|
||||
expect($due->fresh()->isTombstoned())->toBeFalse()
|
||||
->and(Storage::disk('archive')->exists($due->payload_path))->toBeTrue();
|
||||
|
||||
FailingDisk::restore('archive');
|
||||
|
||||
$this->artisan('archive:purge')->expectsOutputToContain('Gelöscht: 1')->assertSuccessful();
|
||||
expect($due->fresh()->isTombstoned())->toBeTrue()
|
||||
->and(Storage::disk('archive')->exists($due->payload_path))->toBeFalse();
|
||||
});
|
||||
|
||||
test('a failed tombstone export or reconcile deletion is reported as failure', function () {
|
||||
/** @var TestCase $this */
|
||||
$pr = PressRelease::factory()->create();
|
||||
$archive = app(EvidenceArchive::class);
|
||||
$due = $archive->record(ArchiveEvent::ImageRemoved, $pr, $pr->id, ArchiveReasonCategory::CustomerRequest, 'customer', null, ['x' => 1]);
|
||||
$this->travelTo(now()->endOfYear()->addYears(3)->addDay());
|
||||
$this->artisan('archive:purge')->assertSuccessful();
|
||||
|
||||
FailingDisk::install('archive', ['put' => fn () => false]);
|
||||
$this->artisan('archive:export-tombstones')->assertFailed();
|
||||
expect(Storage::disk('archive')->exists(config('archive.tombstone_export_path')))->toBeFalse();
|
||||
FailingDisk::restore('archive');
|
||||
|
||||
$this->artisan('archive:export-tombstones')->assertSuccessful();
|
||||
expect(Storage::disk('archive')->exists(config('archive.tombstone_export_path')))->toBeTrue()
|
||||
->and(Storage::disk('archive')->exists(config('archive.tombstone_export_path').'.tmp'))->toBeFalse();
|
||||
|
||||
// „Restore": Payload ist wieder da, Löschung schlägt fehl.
|
||||
Storage::disk('archive')->put($due->payload_path, 'restored-cipher');
|
||||
FailingDisk::install('archive', ['delete' => fn () => false]);
|
||||
$this->artisan('archive:reconcile-after-restore')->expectsOutputToContain('1 fehlgeschlagen')->assertFailed();
|
||||
FailingDisk::restore('archive');
|
||||
});
|
||||
|
||||
test('record leaves neither a row nor a temp file behind when the disk fails, and sweep removes stale orphans', function () {
|
||||
/** @var TestCase $this */
|
||||
$pr = PressRelease::factory()->create();
|
||||
$archive = app(EvidenceArchive::class);
|
||||
|
||||
FailingDisk::install('archive', ['put' => fn () => false]);
|
||||
expect(fn () => $archive->record(ArchiveEvent::GdprChange, $pr, $pr->id, ArchiveReasonCategory::Gdpr, 'system', null, []))->toThrow(RuntimeException::class);
|
||||
FailingDisk::restore('archive');
|
||||
|
||||
FailingDisk::install('archive', ['move' => fn () => false]);
|
||||
expect(fn () => $archive->record(ArchiveEvent::GdprChange, $pr, $pr->id, ArchiveReasonCategory::Gdpr, 'system', null, []))->toThrow(RuntimeException::class);
|
||||
FailingDisk::restore('archive');
|
||||
|
||||
expect(ArchiveEntry::query()->count())->toBe(0)
|
||||
->and(Storage::disk('archive')->allFiles('entries'))->toBe([]);
|
||||
|
||||
// Verwaiste Dateien: eine alte (weg), eine frische (bleibt), eine mit Zeile (bleibt).
|
||||
$kept = $archive->record(ArchiveEvent::GdprChange, $pr, $pr->id, ArchiveReasonCategory::Gdpr, 'system', null, []);
|
||||
Storage::disk('archive')->put('entries/2026/stale.bin', 'x');
|
||||
Storage::disk('archive')->put('entries/2026/stale.bin.tmp', 'x');
|
||||
Storage::disk('archive')->put('entries/2026/fresh.bin', 'x');
|
||||
touch(Storage::disk('archive')->path('entries/2026/stale.bin'), time() - 90000);
|
||||
touch(Storage::disk('archive')->path('entries/2026/stale.bin.tmp'), time() - 90000);
|
||||
touch(Storage::disk('archive')->path($kept->payload_path), time() - 90000);
|
||||
|
||||
$this->artisan('archive:sweep-orphans', ['--dry-run' => true])->expectsOutputToContain('2 verwaist, 0 entfernt')->assertSuccessful();
|
||||
$this->artisan('archive:sweep-orphans')->expectsOutputToContain('2 verwaist, 2 entfernt')->assertSuccessful();
|
||||
|
||||
expect(Storage::disk('archive')->exists('entries/2026/stale.bin'))->toBeFalse()
|
||||
->and(Storage::disk('archive')->exists('entries/2026/stale.bin.tmp'))->toBeFalse()
|
||||
->and(Storage::disk('archive')->exists('entries/2026/fresh.bin'))->toBeTrue()
|
||||
->and(Storage::disk('archive')->exists($kept->payload_path))->toBeTrue();
|
||||
});
|
||||
|
||||
test('a failed depublication archive leaves a pending marker that the retry command resolves', function () {
|
||||
/** @var TestCase $this */
|
||||
['pr' => $pr] = archivePublishedWithImage();
|
||||
$this->actingAs(archiveAdmin());
|
||||
|
||||
FailingDisk::install('archive', ['put' => fn () => false]);
|
||||
app(PressReleaseService::class)->depublish($pr);
|
||||
|
||||
expect($pr->fresh()->status)->toBe(PressReleaseStatus::Archived)
|
||||
->and($pr->fresh()->archive_pending_at)->not->toBeNull()
|
||||
->and(ArchiveEntry::query()->count())->toBe(0);
|
||||
|
||||
$health = collect(app(AdminSystemHealth::class)->snapshot()['checks'])->keyBy('key');
|
||||
expect($health->get('archive_pending')['status'])->toBe('warn');
|
||||
|
||||
$this->artisan('archive:retry-pending')->assertFailed();
|
||||
FailingDisk::restore('archive');
|
||||
|
||||
$this->artisan('archive:retry-pending')->expectsOutputToContain('Nachgeholt: 1')->assertSuccessful();
|
||||
|
||||
$entry = ArchiveEntry::query()->where('event', 'pm_depublished')->sole();
|
||||
expect($entry->reason_category)->toBe(ArchiveReasonCategory::CustomerRequest)
|
||||
->and($pr->fresh()->archive_pending_at)->toBeNull()
|
||||
->and(app(EvidenceArchive::class)->manifest($entry, auth()->user(), 'Test')['note'])->toContain('Nachgeholt');
|
||||
|
||||
$this->artisan('archive:retry-pending')->expectsOutputToContain('Nachgeholt: 0')->assertSuccessful();
|
||||
expect(ArchiveEntry::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('a failed anonymisation archive still removes the image and is completed by the retry with the snapshot as before state', function () {
|
||||
/** @var TestCase $this */
|
||||
['pr' => $pr, 'image' => $image] = archivePublishedWithImage();
|
||||
$pr->forceFill(['text' => '<p>Max Mustermann war dabei.</p>'])->save();
|
||||
$legalRequest = LegalRequest::factory()->type(LegalRequestType::Dsgvo)->create(['press_release_id' => $pr->id]);
|
||||
$this->actingAs(archiveAdmin());
|
||||
|
||||
FailingDisk::install('archive', ['put' => fn () => false]);
|
||||
app(AnonymizationService::class)->execute($pr, ['Max Mustermann'], removeImageIds: [$image->id], legalRequest: $legalRequest);
|
||||
FailingDisk::restore('archive');
|
||||
|
||||
expect($pr->fresh()->text)->not->toContain('Max Mustermann')
|
||||
->and($pr->fresh()->archive_pending_at)->not->toBeNull()
|
||||
->and(PressReleaseImage::withTrashed()->whereKey($image->id)->sole()->trashed())->toBeTrue()
|
||||
->and(Storage::disk('local')->exists($image->path))->toBeFalse()
|
||||
->and(Storage::disk('local')->exists($image->original_path))->toBeTrue()
|
||||
->and(ArchiveEntry::query()->count())->toBe(0);
|
||||
|
||||
// Nachträgliche Änderung darf den Vorher-Stand des Nachweises nicht verfälschen.
|
||||
$pr->forceFill(['text' => '<p>Ganz neuer Text.</p>'])->save();
|
||||
|
||||
$this->artisan('archive:retry-pending')->expectsOutputToContain('Nachgeholt: 2')->assertSuccessful();
|
||||
|
||||
$change = ArchiveEntry::query()->where('event', 'gdpr_change')->sole();
|
||||
$removed = ArchiveEntry::query()->where('event', 'image_removed')->sole();
|
||||
$manifest = app(EvidenceArchive::class)->manifest($change, auth()->user(), 'Test');
|
||||
|
||||
expect($change->legal_request_id)->toBe($legalRequest->id)
|
||||
->and($removed->legal_request_id)->toBe($legalRequest->id)
|
||||
->and($removed->reason_category)->toBe(ArchiveReasonCategory::Gdpr)
|
||||
->and($manifest['before']['text'])->toContain('Max Mustermann')
|
||||
->and($manifest['after']['text'])->toContain('Ganz neuer Text')
|
||||
->and($manifest['redaction_snapshot_id'])->toBe(PressReleaseRedactionSnapshot::query()->sole()->id)
|
||||
->and(Storage::disk('local')->exists($image->original_path))->toBeFalse()
|
||||
->and($pr->fresh()->archive_pending_at)->toBeNull();
|
||||
});
|
||||
|
||||
test('an anonymisation removes images through the archive lifecycle and links entry and activity to the request', function () {
|
||||
['pr' => $pr, 'image' => $image] = archivePublishedWithImage();
|
||||
$legalRequest = LegalRequest::factory()->type(LegalRequestType::Dsgvo)->create(['press_release_id' => $pr->id]);
|
||||
$admin = archiveAdmin();
|
||||
$this->actingAs($admin);
|
||||
|
||||
app(AnonymizationService::class)->execute($pr, [], removeImageIds: [$image->id], legalRequest: $legalRequest);
|
||||
|
||||
$removed = ArchiveEntry::query()->where('event', 'image_removed')->sole();
|
||||
$change = ArchiveEntry::query()->where('event', 'gdpr_change')->sole();
|
||||
|
||||
expect($removed->reason_category)->toBe(ArchiveReasonCategory::Gdpr)
|
||||
->and($removed->legal_request_id)->toBe($legalRequest->id)
|
||||
->and($removed->actor_type)->toBe('admin')
|
||||
->and(app(EvidenceArchive::class)->manifest($removed, $admin, 'Test')['legal_request']['id'])->toBe($legalRequest->id)
|
||||
->and(Storage::disk('local')->exists($image->original_path))->toBeFalse()
|
||||
->and(PressReleaseImage::withTrashed()->whereKey($image->id)->sole()->trashed())->toBeTrue()
|
||||
->and($pr->fresh()->archive_pending_at)->toBeNull()
|
||||
->and(ArchiveEntry::query()->where('legal_request_id', $legalRequest->id)->count())->toBe(2);
|
||||
|
||||
$activity = ActivityEvent::query()->where('type', ActivityType::Anonymization->value)->latest('id')->first();
|
||||
expect($activity->meta['archive_entry_id'])->toBe($change->id);
|
||||
|
||||
$this->get('https://pressekonto.test/admin/archive?legalRequest='.$legalRequest->id)
|
||||
->assertSuccessful()
|
||||
->assertSeeText('Vorgang #'.$legalRequest->id);
|
||||
});
|
||||
|
||||
test('the tombstone of a removed image also purges confirmation, correction and image row in the application', function () {
|
||||
/** @var TestCase $this */
|
||||
['pr' => $pr, 'image' => $image] = archivePublishedWithImage();
|
||||
$admin = archiveAdmin();
|
||||
|
||||
app(ImageCorrectionService::class)->correctCredit($image, $admin, 'Neuer Nachweis', 'Einwand');
|
||||
app(PressReleaseImageRemover::class)->remove($image->fresh(), $admin);
|
||||
|
||||
expect(ImageRightsConfirmation::query()->where('subject_id', $image->id)->count())->toBe(1)
|
||||
->and(ImageDeclarationCorrection::query()->where('press_release_image_id', $image->id)->count())->toBe(1);
|
||||
|
||||
$this->travelTo(now()->endOfYear()->addYears(3)->addDay());
|
||||
$this->artisan('archive:purge')->assertSuccessful();
|
||||
|
||||
expect(ArchiveEntry::query()->where('event', 'image_removed')->sole()->isTombstoned())->toBeTrue()
|
||||
->and(ImageRightsConfirmation::query()->where('subject_id', $image->id)->count())->toBe(0)
|
||||
->and(ImageDeclarationCorrection::query()->where('press_release_image_id', $image->id)->count())->toBe(0)
|
||||
->and(PressReleaseImage::withTrashed()->whereKey($image->id)->exists())->toBeFalse()
|
||||
->and(PressRelease::query()->whereKey($pr->id)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('reconcile also uses database tombstones when the export on the restored disk is stale', function () {
|
||||
/** @var TestCase $this */
|
||||
$pr = PressRelease::factory()->create();
|
||||
$archive = app(EvidenceArchive::class);
|
||||
$due = $archive->record(ArchiveEvent::ImageRemoved, $pr, $pr->id, ArchiveReasonCategory::CustomerRequest, 'customer', null, ['x' => 1]);
|
||||
$this->travelTo(now()->endOfYear()->addYears(3)->addDay());
|
||||
$this->artisan('archive:purge')->assertSuccessful();
|
||||
|
||||
// Disk auf alten Stand: Payload wieder da, kein Export vorhanden.
|
||||
Storage::disk('archive')->put($due->payload_path, 'restored-cipher');
|
||||
Storage::disk('archive')->delete(config('archive.tombstone_export_path'));
|
||||
|
||||
$this->artisan('archive:reconcile-after-restore')->expectsOutputToContain('1 Dateien entfernt, 0 fehlgeschlagen')->assertSuccessful();
|
||||
expect(Storage::disk('archive')->exists($due->payload_path))->toBeFalse();
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue