presseportale/tests/Feature/Archive/EvidenceArchiveFollowUpTest.php
Kevin Adametz 60650baeb8 archive: Auftragstabelle statt Marker, exklusiver Abschluss, Quarantäne, Restore über Payload-Identität und signierte Exporte, Schlüsselrotation (Umsetzungsaudit 29.09.: A-01 bis A-15, OPT-6, 5.1 d; 02.10.: O04, O05, O07, O08)
- A-01/A-02/A-04: archive_pending_jobs je Vorgang (ArchivePendingQueue),
  Depublikationsstand am Auftrag festgehalten, Nachholer ohne Schlüssel
  FAILURE; archive_pending_at nur noch Sammelmarker
- O07: Direktweg und Nachholer beanspruchen den Auftrag unter Zeilensperre
- O08: fehlende Snapshot-Datei → Verweis auf den image_removed-Eintrag oder
  offener Auftrag, nie ein still unvollständiger Nachweis
- A-03/A-06: Sweep verschiebt in die Quarantäne, Pause beim Restore,
  archive:reregister; Restore-Abgleich löscht Anwendungskopien erneut
- O04/O05: Restore ordnet über den Payload-Pfad zu; unsignierte Exporte nur
  mit --accept-unsigned, reregister bricht bei ungültigem/fehlendem Export ab
- A-05, A-07 bis A-15: Altbestand als möglicherweise veröffentlicht, Hold-
  Historie, Zweck per POST, Nachweise gelöschter Bilder nur mit
  compliance:manage, Anhänge auf ihrer Disk, Schlüssel-ID und
  ARCHIVE_PREVIOUS_KEYS, HMAC per HKDF, archive:verify-export
- 5.1 d: Depublikation im Meldeverfahren trägt legal_request_id

PressReleaseService.php enthält zusätzlich K-01 (Prüfung im Publish-Lock),
N19 (archive() gibt Kopien frei) und N09 (markImagesPublished nur für
auslieferbare Bilder), weil die Datei ungeteilt committet ist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:09:01 +00:00

826 lines
41 KiB
PHP

<?php
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\AppSetting;
use App\Models\ArchiveAccessLog;
use App\Models\ArchiveEntry;
use App\Models\ArchivePendingJob;
use App\Models\Category;
use App\Models\Company;
use App\Models\Contact;
use App\Models\ImageDeclarationCorrection;
use App\Models\ImageRightsConfirmation;
use App\Models\LegalRequest;
use App\Models\PressRelease;
use App\Models\PressReleaseAttachment;
use App\Models\PressReleaseImage;
use App\Models\PressReleaseRedactionSnapshot;
use App\Models\User;
use App\Services\Admin\AdminSystemHealth;
use App\Services\Archive\ArchivePendingQueue;
use App\Services\Archive\EvidenceArchive;
use App\Services\Archive\PendingArchiveRetry;
use App\Services\CurrentPortalContext;
use App\Services\PressRelease\AnonymizationService;
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\Schema;
use Illuminate\Support\Facades\Storage;
use Tests\Support\FailingDisk;
use Tests\TestCase;
/**
* Beweisarchiv, Löschung, Anonymisierung — Umsetzungsaudit 29.09.2026
* (A-01 bis A-15, OPT-6, 5.1 d, PortalScope im Archiver).
*/
beforeEach(function (): void {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
Storage::fake('archive');
Storage::fake('local');
Storage::fake('public');
Mail::fake();
config()->set('archive.key', 'base64:'.base64_encode(random_bytes(32)));
config()->set('archive.previous_keys', []);
config()->set('archive.sweep_paused', false);
AppSetting::forgetCached('archive.sweep_paused');
});
afterEach(function (): void {
CurrentPortalContext::clear();
});
function followUpAdmin(string $role = 'admin'): User
{
$admin = User::factory()->create(['is_active' => true, 'name' => 'Erika Prüferin']);
$admin->assignRole($role);
return $admin;
}
/**
* @return array{pr: PressRelease, image: PressReleaseImage}
*/
function followUpPublished(array $imageAttributes = []): array
{
$category = Category::factory()->create(['portal' => Portal::Presseecho]);
$category->translations()->create(['locale' => 'de', 'name' => 'Energie', 'slug' => 'energie-'.$category->id]);
$company = Company::factory()->presseecho()->create(['name' => 'Beispiel AG', 'is_active' => true]);
$pr = PressRelease::factory()->published()->forPortal(Portal::Presseecho)->for($category)->for($company)->create([
'published_ever_at' => now()->subDay(),
'text' => '<p>Max Mustermann war dabei.</p>',
'slug' => 'folge-'.fake()->unique()->numberBetween(1, 99999),
]);
$image = PressReleaseImage::factory()->for($pr)->rulesV2()->create($imageAttributes + [
'copyright' => 'Anna Beispiel',
'published_ever_at' => now()->subDay(),
'published_snapshot_path' => 'press-release-published/'.$pr->id.'/snap.jpg',
'published_snapshot_sha256' => hash('sha256', 'snapshot-bytes'),
]);
Storage::disk('local')->put($image->path, 'cover-bytes');
Storage::disk('local')->put($image->original_path, 'original-bytes');
Storage::disk('local')->put('press-release-published/'.$pr->id.'/snap.jpg', 'snapshot-bytes');
ImageRightsConfirmation::query()->create([
'subject_type' => $image->getMorphClass(),
'subject_id' => $image->getKey(),
'press_release_id' => $pr->id,
'company_id' => $pr->company_id,
'user_id' => $pr->user_id,
'channel' => ConfirmationChannel::Form,
'legal_text_key' => 'img-confirm-v2',
'text_snapshot' => 'Ich bestätige …',
'agb_version' => '0.6',
'file_sha256' => $image->original_sha256,
'declaration_snapshot' => [],
'confirmed_at' => now(),
'created_at' => now(),
]);
return ['pr' => $pr, 'image' => $image->fresh()];
}
/**
* @return array<string, mixed>
*/
function followUpManifest(ArchiveEntry $entry): array
{
return app(EvidenceArchive::class)->manifest($entry, followUpAdmin(), 'Test');
}
// ---------------------------------------------------------------------------
// A-01: Nachholer ohne Schlüssel
// ---------------------------------------------------------------------------
test('without an archive key the retry fails, keeps every job and the system status reports the missing key', function () {
/** @var TestCase $this */
['pr' => $pr] = followUpPublished();
$this->actingAs(followUpAdmin());
config()->set('archive.key', null);
app(PressReleaseService::class)->depublish($pr);
expect(ArchivePendingJob::query()->count())->toBe(1)
->and($pr->fresh()->archive_pending_at)->not->toBeNull();
$this->artisan('archive:retry-pending')->expectsOutputToContain('ARCHIVE_KEY')->assertFailed();
expect(ArchivePendingJob::query()->count())->toBe(1)
->and($pr->fresh()->archive_pending_at)->not->toBeNull()
->and(ArchiveEntry::query()->count())->toBe(0);
$checks = collect(app(AdminSystemHealth::class)->snapshot()['checks'])->keyBy('key');
expect($checks->get('archive_key')['status'])->toBe('critical')
->and($checks->get('archive_pending')['detail'])->toContain('1 offene(r) Archivauftrag');
config()->set('archive.key', 'base64:'.base64_encode(random_bytes(32)));
$this->artisan('archive:retry-pending')->expectsOutputToContain('Nachgeholt: 1')->assertSuccessful();
$checks = collect(app(AdminSystemHealth::class)->snapshot()['checks'])->keyBy('key');
expect($checks->get('archive_key')['status'])->toBe('ok')
->and($checks->get('archive_pending')['status'])->toBe('ok');
});
// ---------------------------------------------------------------------------
// A-02 / A-04: Aufträge je Vorgang statt eines Zeitstempels
// ---------------------------------------------------------------------------
test('a later successful anonymisation neither overwrites nor clears the open depublication job', function () {
/** @var TestCase $this */
['pr' => $pr] = followUpPublished();
$this->actingAs(followUpAdmin());
FailingDisk::install('archive', ['put' => fn () => false]);
app(PressReleaseService::class)->depublish($pr);
FailingDisk::restore('archive');
$marker = $pr->fresh()->archive_pending_at;
expect($marker)->not->toBeNull();
$this->travel(2)->hours();
app(AnonymizationService::class)->execute($pr->fresh(), ['Max Mustermann']);
expect(ArchiveEntry::query()->where('event', 'gdpr_change')->count())->toBe(1)
->and(ArchivePendingJob::query()->pluck('event')->all())->toBe(['pm_depublished'])
->and($pr->fresh()->archive_pending_at?->toDateTimeString())->toBe($marker->toDateTimeString());
$this->artisan('archive:retry-pending')->expectsOutputToContain('Nachgeholt: 1')->assertSuccessful();
// A-04: der Nachweis zeigt den Stand der Depublikation, nicht den geschwärzten.
$depublished = ArchiveEntry::query()->where('event', 'pm_depublished')->sole();
expect(followUpManifest($depublished)['press_release']['text'])->toContain('Max Mustermann')
->and($pr->fresh()->archive_pending_at)->toBeNull()
->and(ArchivePendingJob::query()->count())->toBe(0);
});
test('a retried depublication archives the state at depublication even after later edits', function () {
/** @var TestCase $this */
['pr' => $pr] = followUpPublished();
$this->actingAs(followUpAdmin());
FailingDisk::install('archive', ['put' => fn () => false]);
app(PressReleaseService::class)->depublish($pr);
FailingDisk::restore('archive');
$pr->fresh()->forceFill(['text' => '<p>Später bearbeitet.</p>', 'title' => 'Neuer Titel'])->save();
$this->artisan('archive:retry-pending')->assertSuccessful();
$manifest = followUpManifest(ArchiveEntry::query()->where('event', 'pm_depublished')->sole());
expect($manifest['press_release']['text'])->toContain('Max Mustermann')
->and($manifest['press_release']['title'])->not->toBe('Neuer Titel')
->and($manifest['images'])->toHaveCount(1)
->and($manifest['images'][0])->not->toHaveKey('snapshot_path');
});
test('a retried depublication without its snapshot file stays open, or points to the image archive entry', function () {
/** @var TestCase $this */
// Umsetzungsaudit 02.10.2026, O08: Eine fehlende Snapshot-Datei wurde
// still ausgelassen und der Auftrag trotzdem als erledigt verbucht.
['pr' => $pr, 'image' => $image] = followUpPublished();
$admin = followUpAdmin();
$this->actingAs($admin);
$snapshot = (string) $image->published_snapshot_path;
FailingDisk::install('archive', ['put' => fn () => false]);
app(PressReleaseService::class)->depublish($pr);
FailingDisk::restore('archive');
// Datei verloren, Bild nirgends archiviert: sichtbar offen bleiben.
Storage::disk('local')->move($snapshot, $snapshot.'.weg');
$this->artisan('archive:retry-pending')->assertFailed();
expect(ArchiveEntry::query()->where('event', 'pm_depublished')->exists())->toBeFalse()
->and(ArchivePendingJob::query()->where('event', 'pm_depublished')->sole()->last_error)->toContain('Snapshot');
// Das Bild wird regulär entfernt und mit seinen Snapshots archiviert;
// der Remover räumt die lokalen Dateien danach weg.
Storage::disk('local')->move($snapshot.'.weg', $snapshot);
app(PressReleaseImageRemover::class)->remove($image->fresh(), $admin);
$removed = ArchiveEntry::query()->where('event', 'image_removed')->sole();
expect(Storage::disk('local')->exists($snapshot))->toBeFalse();
$this->artisan('archive:retry-pending')->assertSuccessful();
$manifest = followUpManifest(ArchiveEntry::query()->where('event', 'pm_depublished')->sole());
expect($manifest['images'][0]['archived_in'])->toBe(['archive_entry_id' => $removed->id, 'event' => 'image_removed'])
->and($manifest['images'][0])->not->toHaveKey('file');
});
test('a failed anonymisation of an already archived release does not invent a depublication entry', function () {
/** @var TestCase $this */
['pr' => $pr] = followUpPublished();
$this->actingAs(followUpAdmin());
app(PressReleaseService::class)->archive($pr);
expect(ArchiveEntry::query()->where('event', 'pm_depublished')->count())->toBe(1);
FailingDisk::install('archive', ['put' => fn () => false]);
app(AnonymizationService::class)->execute($pr->fresh(), ['Max Mustermann']);
FailingDisk::restore('archive');
$this->artisan('archive:retry-pending')->expectsOutputToContain('Nachgeholt: 1')->assertSuccessful();
expect(ArchiveEntry::query()->where('event', 'pm_depublished')->count())->toBe(1)
->and(ArchiveEntry::query()->where('event', 'gdpr_change')->count())->toBe(1);
// Nachher-Stand ist der Stand der Schwärzung, nicht der beim Nachholen.
$manifest = followUpManifest(ArchiveEntry::query()->where('event', 'gdpr_change')->sole());
expect($manifest['after']['text'])->toContain('[entfernt]');
});
test('pushing a job keeps an older marker and never touches updated_at', function () {
/** @var TestCase $this */
['pr' => $pr] = followUpPublished();
$older = now()->subDay()->startOfSecond();
DB::table('press_releases')->where('id', $pr->id)->update(['archive_pending_at' => $older, 'updated_at' => now()->subWeek()]);
$updatedAt = $pr->fresh()->updated_at->toDateTimeString();
app(ArchivePendingQueue::class)->push($pr->id, ArchiveEvent::GdprChange, $pr, ArchiveReasonCategory::Gdpr, payload: ['before' => [], 'after' => []]);
expect($pr->fresh()->archive_pending_at->toDateTimeString())->toBe($older->toDateTimeString())
->and($pr->fresh()->updated_at->toDateTimeString())->toBe($updatedAt)
// Der Altmarker ohne Auftrag wird zum eigenen Auftrag und geht nicht verloren.
->and(ArchivePendingJob::query()->orderBy('id')->pluck('event')->all())->toBe(['legacy_marker', 'gdpr_change']);
});
test('the queue api records a label correction job that the retry command completes', function () {
/** @var TestCase $this */
['pr' => $pr, 'image' => $image] = followUpPublished();
$admin = followUpAdmin();
$correction = ImageDeclarationCorrection::query()->create([
'press_release_image_id' => $image->id,
'press_release_id' => $pr->id,
'admin_id' => $admin->id,
'field' => ImageDeclarationCorrection::FIELD_CREDIT,
'old_value' => 'Anna Beispiel',
'new_value' => 'Bernd Beispiel',
'reason' => 'Einwand',
'created_at' => now(),
]);
$queue = app(ArchivePendingQueue::class);
$job = DB::transaction(fn () => $queue->push($pr->id, ArchiveEvent::LabelCorrected, $correction, ArchiveReasonCategory::Correction, actorId: $admin->id));
expect($job->event)->toBe('label_corrected')
->and($pr->fresh()->archive_pending_at)->not->toBeNull();
$this->artisan('archive:retry-pending')->expectsOutputToContain('Nachgeholt: 1')->assertSuccessful();
$entry = ArchiveEntry::query()->where('event', 'label_corrected')->sole();
expect($entry->summary['correction_id'])->toBe($correction->id)
->and($entry->actor_type)->toBe('admin')
->and($pr->fresh()->archive_pending_at)->toBeNull();
// completeWith: Auftrag fällt mit dem Eintrag, Marker nur ohne weitere Aufträge.
$second = DB::transaction(fn () => $queue->push($pr->id, ArchiveEvent::LabelCorrected, $correction, ArchiveReasonCategory::Correction, actorId: $admin->id));
$entry = $queue->completeWith($second, fn () => null);
expect($entry)->toBeNull()
->and($second->fresh()->attempts)->toBe(1)
->and($second->fresh()->last_error)->toContain('nicht archiviert');
});
test('a job completed elsewhere is not archived a second time from a stale instance', function () {
/** @var TestCase $this */
// Umsetzungsaudit 02.10.2026, O07: Direktweg und Nachholer starteten
// mit einer geladenen Instanz, ohne die Auftragszeile zu beanspruchen.
['pr' => $pr] = followUpPublished();
$queue = app(ArchivePendingQueue::class);
$job = DB::transaction(fn () => $queue->push($pr->id, ArchiveEvent::GdprChange, null, ArchiveReasonCategory::Gdpr, payload: ['before' => ['text' => 'alt'], 'after' => ['text' => 'neu']]));
$stale = clone $job;
$first = app(PendingArchiveRetry::class)->retry($job);
$second = app(PendingArchiveRetry::class)->retry($stale);
expect($first['recorded'])->toBe(1)
->and($second)->toBe(['recorded' => 0, 'skipped' => 0, 'failed' => 0])
->and(ArchiveEntry::query()->where('event', ArchiveEvent::GdprChange->value)->count())->toBe(1);
// Direktweg mit veralteter Instanz: der Schritt läuft gar nicht erst,
// und es wird kein Fehlversuch verbucht.
$ran = false;
expect($queue->completeWith($stale, function () use (&$ran) {
$ran = true;
return null;
}))->toBeNull()
->and($ran)->toBeFalse()
->and(ArchiveEntry::query()->where('event', ArchiveEvent::GdprChange->value)->count())->toBe(1);
});
// ---------------------------------------------------------------------------
// A-10, A-12: Anonymisierung
// ---------------------------------------------------------------------------
test('a failed immediate deletion of a never published image during anonymisation stays queued and is completed later', function () {
/** @var TestCase $this */
['pr' => $pr] = followUpPublished();
$image = PressReleaseImage::factory()->for($pr)->rulesV2()->create(['published_ever_at' => null]);
Storage::disk('local')->put($image->original_path, 'original-bytes');
$this->actingAs(followUpAdmin());
FailingDisk::install('local', ['delete' => fn () => false]);
app(AnonymizationService::class)->execute($pr, [], removeImageIds: [$image->id]);
FailingDisk::restore('local');
expect(PressReleaseImage::withTrashed()->whereKey($image->id)->sole()->trashed())->toBeTrue()
->and(Storage::disk('local')->exists($image->original_path))->toBeTrue()
->and(ArchivePendingJob::query()->where('event', 'image_purge')->count())->toBe(1)
->and($pr->fresh()->archive_pending_at)->not->toBeNull();
$this->artisan('archive:retry-pending')->assertSuccessful();
expect(PressReleaseImage::withTrashed()->whereKey($image->id)->exists())->toBeFalse()
->and(Storage::disk('local')->exists($image->original_path))->toBeFalse()
->and($pr->fresh()->archive_pending_at)->toBeNull();
});
test('anonymisation deletes attachments on the disk they actually live on', function () {
['pr' => $pr] = followUpPublished();
$local = PressReleaseAttachment::factory()->for($pr)->create(['disk' => 'local', 'path' => 'press-release-attachments/'.$pr->id.'/a.pdf']);
$public = PressReleaseAttachment::factory()->for($pr)->create(['disk' => 'public', 'path' => 'press-release-attachments/'.$pr->id.'/b.pdf']);
Storage::disk('local')->put($local->path, 'pdf');
Storage::disk('public')->put($public->path, 'pdf');
app(AnonymizationService::class)->execute($pr, [], removeAttachmentIds: [$local->id, $public->id]);
expect(Storage::disk('local')->exists($local->path))->toBeFalse()
->and(Storage::disk('public')->exists($public->path))->toBeFalse();
});
// ---------------------------------------------------------------------------
// A-13: Snapshots bleiben bei offenem Auftrag
// ---------------------------------------------------------------------------
test('snapshots of releases with an open archive job survive the snapshot purge', function () {
/** @var TestCase $this */
['pr' => $pending] = followUpPublished();
['pr' => $done] = followUpPublished();
$keep = PressReleaseRedactionSnapshot::capture($pending, ['fields' => ['text' => 'alt']]);
$drop = PressReleaseRedactionSnapshot::capture($done, ['fields' => ['text' => 'alt']]);
PressReleaseRedactionSnapshot::query()->update(['purge_after' => now()->subDays(2)->toDateString()]);
DB::transaction(fn () => app(ArchivePendingQueue::class)->push($pending->id, ArchiveEvent::GdprChange, $keep, ArchiveReasonCategory::Gdpr, payload: ['before' => [], 'after' => []]));
$this->artisan('snapshots:purge')->assertSuccessful();
expect(PressReleaseRedactionSnapshot::query()->whereKey($keep->id)->exists())->toBeTrue()
->and(PressReleaseRedactionSnapshot::query()->whereKey($drop->id)->exists())->toBeFalse();
});
// ---------------------------------------------------------------------------
// A-03: Sweep nach Restore — Quarantäne, Schalter, Wiederregistrierung
// ---------------------------------------------------------------------------
test('the orphan sweep moves payloads into quarantine and deletes them only after the quarantine period', function () {
/** @var TestCase $this */
Storage::disk('archive')->put('entries/2026/stale.bin', 'x');
Storage::disk('archive')->put('entries/2026/stale.bin.tmp', 'x');
touch(Storage::disk('archive')->path('entries/2026/stale.bin'), time() - 90000);
touch(Storage::disk('archive')->path('entries/2026/stale.bin.tmp'), time() - 90000);
$this->artisan('archive:sweep-orphans')->expectsOutputToContain('2 verwaist, 1 in Quarantäne, 1 entfernt')->assertSuccessful();
$quarantined = 'quarantine/'.now()->toDateString().'/entries/2026/stale.bin';
expect(Storage::disk('archive')->exists('entries/2026/stale.bin'))->toBeFalse()
->and(Storage::disk('archive')->exists($quarantined))->toBeTrue();
$this->travel(29)->days();
$this->artisan('archive:sweep-orphans')->assertSuccessful();
expect(Storage::disk('archive')->exists($quarantined))->toBeTrue();
$this->travel(2)->days();
$this->artisan('archive:sweep-orphans')->expectsOutputToContain('Quarantäne abgelaufen: 1')->assertSuccessful();
expect(Storage::disk('archive')->exists($quarantined))->toBeFalse();
});
test('the sweep can be paused by config or switch and the restore reconcile pauses it automatically', function () {
/** @var TestCase $this */
Storage::disk('archive')->put('entries/2026/stale.bin', 'x');
touch(Storage::disk('archive')->path('entries/2026/stale.bin'), time() - 90000);
config()->set('archive.sweep_paused', true);
$this->artisan('archive:sweep-orphans')->expectsOutputToContain('ausgesetzt')->assertSuccessful();
expect(Storage::disk('archive')->exists('entries/2026/stale.bin'))->toBeTrue();
config()->set('archive.sweep_paused', false);
$this->artisan('archive:reconcile-after-restore')->expectsOutputToContain('Sweep ausgesetzt')->assertSuccessful();
$this->artisan('archive:sweep-orphans')->expectsOutputToContain('ausgesetzt')->assertSuccessful();
$checks = collect(app(AdminSystemHealth::class)->snapshot()['checks'])->keyBy('key');
expect($checks->get('archive_sweep')['status'])->toBe('warn');
$this->artisan('archive:sweep-orphans', ['--resume' => true])->assertSuccessful();
$this->artisan('archive:sweep-orphans')->expectsOutputToContain('1 in Quarantäne')->assertSuccessful();
$this->artisan('archive:sweep-orphans', ['--pause' => true])->assertSuccessful();
expect(AppSetting::getBool('archive.sweep_paused'))->toBeTrue();
});
test('reregister restores archive rows for payloads from entries and quarantine using their manifest', function () {
/** @var TestCase $this */
['pr' => $pr] = followUpPublished();
$legalRequest = LegalRequest::factory()->type(LegalRequestType::Dsgvo)->create(['press_release_id' => $pr->id]);
$archive = app(EvidenceArchive::class);
$first = $archive->record(ArchiveEvent::ImageRemoved, $pr, $pr->id, ArchiveReasonCategory::Gdpr, 'admin', 7, ['x' => 1], [], ['files' => 0], $legalRequest->id);
$second = $archive->record(ArchiveEvent::PressReleaseDepublished, $pr, $pr->id, ArchiveReasonCategory::OwnDecision, 'system', null, ['x' => 2]);
// „DB-Restore auf einen Stand vor beiden Einträgen": Zeilen weg, Dateien da.
DB::table('archive_entries')->delete();
touch(Storage::disk('archive')->path($second->payload_path), time() - 90000);
$this->artisan('archive:sweep-orphans')->expectsOutputToContain('1 in Quarantäne')->assertSuccessful();
// Kein Tombstone-Export vorhanden: nur auf ausdrückliche Anweisung (O05).
$this->artisan('archive:reregister')->expectsOutputToContain('Kein Tombstone-Export')->assertFailed();
$this->artisan('archive:reregister', ['--dry-run' => true, '--without-export' => true])->expectsOutputToContain('2 Payload(s) ohne Zeile')->assertSuccessful();
expect(ArchiveEntry::query()->count())->toBe(0);
$this->artisan('archive:reregister', ['--without-export' => true])->expectsOutputToContain('2 wieder registriert')->assertSuccessful();
$restoredFirst = ArchiveEntry::query()->where('payload_path', $first->payload_path)->sole();
$restoredSecond = ArchiveEntry::query()->where('payload_path', $second->payload_path)->sole();
expect($restoredFirst->event)->toBe(ArchiveEvent::ImageRemoved)
->and($restoredFirst->legal_request_id)->toBe($legalRequest->id)
->and($restoredFirst->actor_id)->toBe(7)
->and($restoredFirst->payload_hash)->toBe($first->payload_hash)
->and($restoredFirst->retain_until->toDateString())->toBe($first->retain_until->toDateString())
->and($restoredSecond->event)->toBe(ArchiveEvent::PressReleaseDepublished)
->and(Storage::disk('archive')->exists($second->payload_path))->toBeTrue()
->and(followUpManifest($restoredSecond)['x'])->toBe(2);
$this->artisan('archive:reregister', ['--without-export' => true])->expectsOutputToContain('0 Payload(s) ohne Zeile')->assertSuccessful();
});
// ---------------------------------------------------------------------------
// A-06: Restore-Abgleich löscht Anwendungskopien erneut
// ---------------------------------------------------------------------------
test('the restore reconcile purges restored application copies of a removed image again', function () {
/** @var TestCase $this */
['image' => $image] = followUpPublished();
app(PressReleaseImageRemover::class)->remove($image, followUpAdmin());
$entry = ArchiveEntry::query()->where('event', 'image_removed')->sole();
$imageRow = (array) DB::table('press_release_images')->where('id', $image->id)->first();
$confirmationRows = DB::table('image_rights_confirmations')->where('subject_id', $image->id)->get()->map(fn ($row) => (array) $row)->all();
$this->travelTo(now()->endOfYear()->addYears(3)->addDay());
$this->artisan('archive:purge')->assertSuccessful();
$this->artisan('archive:export-tombstones')->assertSuccessful();
expect(PressReleaseImage::withTrashed()->whereKey($image->id)->exists())->toBeFalse();
// „DB-Restore": Zeilen und Payload kommen zurück.
DB::table('press_release_images')->insert($imageRow);
DB::table('image_rights_confirmations')->insert($confirmationRows);
DB::table('archive_entries')->where('id', $entry->id)->update(['deleted_at' => null]);
Storage::disk('archive')->put($entry->payload_path, 'restored-cipher');
$this->artisan('archive:reconcile-after-restore')->expectsOutputToContain('1 Einträge erneut')->assertSuccessful();
expect($entry->fresh()->isTombstoned())->toBeTrue()
->and(PressReleaseImage::withTrashed()->whereKey($image->id)->exists())->toBeFalse()
->and(ImageRightsConfirmation::query()->where('subject_id', $image->id)->count())->toBe(0);
});
// ---------------------------------------------------------------------------
// A-05, A-14: purge-drafts
// ---------------------------------------------------------------------------
test('legacy rejected releases count as possibly published and keep their media in the draft purge', function () {
/** @var TestCase $this */
['pr' => $legacy] = followUpPublished(['published_ever_at' => null]);
$legacy->forceFill(['status' => PressReleaseStatus::Rejected, 'published_ever_at' => null, 'legacy_id' => 424242, 'legacy_portal' => 'presseecho'])->save();
DB::table('press_releases')->where('id', $legacy->id)->update(['updated_at' => now()->subDays(400)]);
expect($legacy->fresh()->wasEverPublished())->toBeTrue()
->and(PressRelease::query()->neverPublished()->whereKey($legacy->id)->exists())->toBeFalse();
$this->artisan('press-releases:purge-drafts', ['--days' => 180])
->expectsOutputToContain('abgelehnte Einreichungen bereinigt: 0, Bilder gelöscht: 0')
->assertSuccessful();
expect(PressReleaseImage::query()->where('press_release_id', $legacy->id)->count())->toBe(1);
});
test('one broken image does not stop the draft purge, which reports the failure at the end', function () {
/** @var TestCase $this */
['pr' => $broken] = followUpPublished(['published_ever_at' => null]);
['pr' => $healthy] = followUpPublished(['published_ever_at' => null]);
PressRelease::query()->whereKey([$broken->id, $healthy->id])->update(['status' => PressReleaseStatus::Draft->value, 'published_ever_at' => null, 'updated_at' => now()->subDays(200)]);
$brokenImage = $broken->images()->sole();
FailingDisk::install('local', ['delete' => fn ($path, $real) => in_array($brokenImage->original_path, (array) $path, true) ? false : $real()]);
$this->artisan('press-releases:purge-drafts', ['--days' => 180])
->expectsOutputToContain('fehlgeschlagen: 1')
->assertFailed();
FailingDisk::restore('local');
expect(PressReleaseImage::query()->where('press_release_id', $healthy->id)->count())->toBe(0)
->and($healthy->fresh()->status)->toBe(PressReleaseStatus::Archived)
->and(PressReleaseImage::query()->whereKey($brokenImage->id)->exists())->toBeTrue()
->and($broken->fresh()->status)->toBe(PressReleaseStatus::Draft);
});
// ---------------------------------------------------------------------------
// A-07, A-08, A-11: Zugriff und Protokoll
// ---------------------------------------------------------------------------
test('setting and releasing a legal hold is logged with admin name and email and keeps the history', function () {
$admin = followUpAdmin();
$pr = PressRelease::factory()->create();
$archive = app(EvidenceArchive::class);
$entry = $archive->record(ArchiveEvent::GdprChange, $pr, $pr->id, ArchiveReasonCategory::Gdpr, 'system', null, []);
$archive->setLegalHold($entry, $admin, 'Abmahnung Az. 12/2026');
$archive->releaseLegalHold($entry->fresh(), $admin, 'Verfahren beendet');
$archive->manifest($entry->fresh(), $admin, 'Auskunft');
$log = ArchiveAccessLog::query()->orderBy('id')->get();
expect($log->pluck('action')->all())->toBe(['hold_set', 'hold_released', 'manifest'])
->and($log->pluck('purpose')->all())->toBe(['Abmahnung Az. 12/2026', 'Verfahren beendet', 'Auskunft'])
->and($log->first()->admin_name)->toBe('Erika Prüferin')
->and($log->first()->admin_email)->toBe($admin->email);
});
test('archive downloads take the purpose only by post, never from the url', function () {
/** @var TestCase $this */
$admin = followUpAdmin();
$pr = PressRelease::factory()->create();
$entry = app(EvidenceArchive::class)->record(ArchiveEvent::PressReleaseDepublished, $pr, $pr->id, ArchiveReasonCategory::OwnDecision, 'admin', $admin->id, ['t' => 1]);
$this->actingAs($admin)->get('https://pressekonto.test/admin/archive/'.$entry->id.'/download?purpose=Geheim')
->assertSuccessful()
->assertSeeText('Zweck des Zugriffs')
->assertSee('method="post"', false);
expect(ArchiveAccessLog::query()->count())->toBe(0);
$this->actingAs($admin)->post('https://pressekonto.test/admin/archive/'.$entry->id.'/export', ['purpose' => 'Anwalt'])
->assertSuccessful()
->assertHeader('Content-Type', 'application/zip');
expect(ArchiveAccessLog::query()->sole()->purpose)->toBe('Anwalt');
});
test('evidence files take the purpose by post and local leftovers of removed images need compliance rights', function () {
/** @var TestCase $this */
['pr' => $pr, 'image' => $image] = followUpPublished();
$url = 'https://pressekonto.test/admin/press-releases/'.$pr->id.'/images/'.$image->id.'/original';
$editor = followUpAdmin('editor');
$this->actingAs($editor)->get($url.'?purpose=Geheim')->assertSuccessful()->assertSeeText('Zweck des Zugriffs');
$response = $this->actingAs($editor)->post($url, ['purpose' => 'Sichtprüfung']);
$response->assertSuccessful();
expect($response->streamedContent())->toBe('original-bytes');
// Entferntes Bild mit liegengebliebenem Original: nur compliance:manage.
$image->delete();
$this->actingAs($editor)->post($url, ['purpose' => 'Sichtprüfung'])->assertForbidden();
$response = $this->actingAs(followUpAdmin())->post($url, ['purpose' => 'Restnachweis prüfen']);
$response->assertSuccessful();
expect($response->streamedContent())->toBe('original-bytes');
});
// ---------------------------------------------------------------------------
// A-15: Schlüsselrotation und abgeleitete Signaturschlüssel
// ---------------------------------------------------------------------------
test('entries carry a key id and stay readable after a key rotation with the previous key', function () {
$admin = followUpAdmin();
$pr = PressRelease::factory()->create();
$archive = app(EvidenceArchive::class);
$oldKey = config('archive.key');
$old = $archive->record(ArchiveEvent::GdprChange, $pr, $pr->id, ArchiveReasonCategory::Gdpr, 'system', null, ['alt' => true]);
expect($old->key_id)->toBe($archive->keyId())->toHaveLength(16);
config()->set('archive.key', 'base64:'.base64_encode(random_bytes(32)));
expect(fn () => $archive->manifest($old->fresh(), $admin, 'Test'))->toThrow(RuntimeException::class);
config()->set('archive.previous_keys', [$oldKey]);
$new = $archive->record(ArchiveEvent::GdprChange, $pr, $pr->id, ArchiveReasonCategory::Gdpr, 'system', null, ['neu' => true]);
expect($archive->manifest($old->fresh(), $admin, 'Test')['alt'])->toBeTrue()
->and($archive->manifest($new->fresh(), $admin, 'Test')['neu'])->toBeTrue()
->and($new->key_id)->not->toBe($old->key_id);
// Einträge vor der Rotation ohne Schlüssel-ID: alle bekannten Schlüssel werden probiert.
DB::table('archive_entries')->where('id', $old->id)->update(['key_id' => null]);
expect($archive->manifest($old->fresh(), $admin, 'Test')['alt'])->toBeTrue();
$checks = collect(app(AdminSystemHealth::class)->snapshot()['checks'])->keyBy('key');
expect($checks->get('archive_key')['status'])->toBe('ok');
DB::table('archive_entries')->where('id', $old->id)->update(['key_id' => $old->key_id]);
config()->set('archive.previous_keys', []);
$checks = collect(app(AdminSystemHealth::class)->snapshot()['checks'])->keyBy('key');
expect($checks->get('archive_key')['status'])->toBe('critical')
->and($checks->get('archive_key')['detail'])->toContain('unbekanntem Schlüssel');
});
test('exports are signed with a derived key and old exports signed with the raw key still verify', function () {
/** @var TestCase $this */
$admin = followUpAdmin();
$pr = PressRelease::factory()->create();
$archive = app(EvidenceArchive::class);
$entry = $archive->record(ArchiveEvent::GdprChange, $pr, $pr->id, ArchiveReasonCategory::Gdpr, 'system', null, []);
$export = $archive->export($entry, $admin, 'Anwalt');
$temp = tempnam(sys_get_temp_dir(), 'pk-test-');
file_put_contents($temp, $export);
$zip = new ZipArchive;
$zip->open($temp);
$signature = (string) $zip->getFromName('signature.txt');
$zip->close();
$rawKey = base64_decode(substr((string) config('archive.key'), 7));
expect($signature)->toContain('hmac-sha256-hkdf=')
->and($signature)->toContain('key-id='.$archive->keyId())
->and($signature)->not->toContain(hash_hmac('sha256', (string) $entry->payload_hash, $rawKey))
->and($archive->verifyExportSignature((string) $entry->payload_hash, $signature))->toBeTrue()
->and($archive->verifyExportSignature(str_repeat('0', 64), $signature))->toBeFalse();
$legacy = 'sha256=abc'."\nhmac-sha256=".hash_hmac('sha256', (string) $entry->payload_hash, $rawKey)."\n";
expect($archive->verifyExportSignature((string) $entry->payload_hash, $legacy))->toBeTrue();
$this->artisan('archive:verify-export', ['path' => $temp])->expectsOutputToContain('gültig')->assertSuccessful();
unlink($temp);
});
test('a tampered tombstone export is refused by the restore reconcile', function () {
/** @var TestCase $this */
$pr = PressRelease::factory()->create();
$archive = app(EvidenceArchive::class);
$due = $archive->record(ArchiveEvent::GdprChange, $pr, $pr->id, ArchiveReasonCategory::Gdpr, 'system', null, []);
$victim = $archive->record(ArchiveEvent::GdprChange, $pr, $pr->id, ArchiveReasonCategory::Gdpr, 'system', null, []);
DB::table('archive_entries')->where('id', $victim->id)->update(['retain_until' => now()->addYears(10)->toDateString()]);
$this->travelTo(now()->endOfYear()->addYears(3)->addDay());
$this->artisan('archive:purge')->assertSuccessful();
$this->artisan('archive:export-tombstones')->assertSuccessful();
$path = (string) config('archive.tombstone_export_path');
$export = json_decode((string) Storage::disk('archive')->get($path), true);
expect($export['hmac'])->toBeString()
->and($export['entries'][0]['id'])->toBe($due->id);
$export['entries'][] = ['id' => $victim->id, 'payload_path' => $victim->payload_path, 'deleted_at' => now()->toIso8601String()];
Storage::disk('archive')->put($path, (string) json_encode($export));
$this->artisan('archive:reconcile-after-restore')->expectsOutputToContain('Signatur ungültig')->assertFailed();
expect(Storage::disk('archive')->exists($victim->payload_path))->toBeTrue()
->and($victim->fresh()->isTombstoned())->toBeFalse();
});
/**
* Zwei Einträge, der erste nach Fristende gelöscht und exportiert.
*
* @return array{due: ArchiveEntry, victim: ArchiveEntry, path: string}
*/
function followUpPurgedExport(): array
{
$pr = PressRelease::factory()->create();
$archive = app(EvidenceArchive::class);
$due = $archive->record(ArchiveEvent::GdprChange, $pr, $pr->id, ArchiveReasonCategory::Gdpr, 'system', null, []);
$victim = $archive->record(ArchiveEvent::GdprChange, $pr, $pr->id, ArchiveReasonCategory::Gdpr, 'system', null, []);
DB::table('archive_entries')->where('id', $victim->id)->update(['retain_until' => now()->addYears(10)->toDateString()]);
test()->travelTo(now()->endOfYear()->addYears(3)->addDay());
test()->artisan('archive:purge')->assertSuccessful();
test()->artisan('archive:export-tombstones')->assertSuccessful();
return ['due' => $due, 'victim' => $victim, 'path' => (string) config('archive.tombstone_export_path')];
}
// ---------------------------------------------------------------------------
// Umsetzungsaudit 02.10.2026, O04/O05: Restore-Identität und Export-Vertrauen
// ---------------------------------------------------------------------------
test('the restore reconcile never tombstones a different entry that reuses an exported id', function () {
/** @var TestCase $this */
['due' => $due, 'victim' => $other] = followUpPurgedExport();
// „Älterer DB-Stand": die gelöschte Zeile fehlt, ID 1 trägt jetzt einen
// anderen Eintrag mit eigener Payload.
DB::table('archive_entries')->where('id', $due->id)->delete();
DB::table('archive_entries')->where('id', $other->id)->update(['id' => $due->id]);
$reused = ArchiveEntry::query()->findOrFail($due->id);
$this->artisan('archive:reconcile-after-restore')
->expectsOutputToContain('passen nicht zur Datenbank')
->assertFailed();
expect($reused->fresh()->isTombstoned())->toBeFalse()
->and($reused->payload_path)->not->toBe($due->payload_path)
->and(Storage::disk('archive')->exists($reused->payload_path))->toBeTrue();
});
test('a tombstone export stripped of its signature is not trusted unless explicitly accepted', function (mixed $hmac) {
/** @var TestCase $this */
['victim' => $victim, 'path' => $path] = followUpPurgedExport();
$export = json_decode((string) Storage::disk('archive')->get($path), true);
$export['entries'][] = ['id' => $victim->id, 'payload_path' => $victim->payload_path, 'deleted_at' => now()->toIso8601String()];
if ($hmac === 'missing') {
unset($export['hmac']);
} else {
$export['hmac'] = $hmac;
}
Storage::disk('archive')->put($path, (string) json_encode($export));
$this->artisan('archive:reconcile-after-restore')->expectsOutputToContain('ohne Signatur — verworfen')->assertFailed();
expect($victim->fresh()->isTombstoned())->toBeFalse()
->and(Storage::disk('archive')->exists($victim->payload_path))->toBeTrue();
})->with(['missing', null]);
test('reregister refuses an invalid or unsigned export and registers nothing', function () {
/** @var TestCase $this */
['due' => $due, 'path' => $path] = followUpPurgedExport();
// Gelöschter Eintrag: Zeile weg, Payload (älterer Disk-Stand) wieder da.
DB::table('archive_entries')->where('id', $due->id)->delete();
Storage::disk('archive')->put($due->payload_path, 'restored-cipher');
$export = json_decode((string) Storage::disk('archive')->get($path), true);
$export['hmac'] = str_repeat('0', 64);
Storage::disk('archive')->put($path, (string) json_encode($export));
$this->artisan('archive:reregister')->expectsOutputToContain('ungültiger Signatur')->assertFailed();
unset($export['hmac']);
Storage::disk('archive')->put($path, (string) json_encode($export));
$this->artisan('archive:reregister')->expectsOutputToContain('ohne Signatur')->assertFailed();
expect(ArchiveEntry::query()->where('payload_path', $due->payload_path)->exists())->toBeFalse();
// Ausdrücklich angenommen (Altformat): der Export gilt, der gelöschte
// Eintrag bleibt draußen.
$this->artisan('archive:reregister', ['--accept-unsigned' => true])->expectsOutputToContain('1 laut Tombstone-Export gelöscht')->assertSuccessful();
expect(ArchiveEntry::query()->where('payload_path', $due->payload_path)->exists())->toBeFalse();
});
test('archive_entries.payload_path is indexed for the orphan sweep', function () {
expect(Schema::hasIndex('archive_entries', 'archive_entries_payload_path_idx'))->toBeTrue();
});
// ---------------------------------------------------------------------------
// 5.1 d, PortalScope
// ---------------------------------------------------------------------------
test('a depublication from the report procedure carries the legal request and ignores the portal scope', function () {
/** @var TestCase $this */
['pr' => $pr] = followUpPublished();
$contact = Contact::factory()->create(['portal' => Portal::Presseecho->value, 'company_id' => $pr->company_id]);
$pr->contacts()->attach($contact->id);
$legalRequest = LegalRequest::factory()->type(LegalRequestType::Dsgvo)->create(['press_release_id' => $pr->id]);
$this->actingAs(followUpAdmin());
CurrentPortalContext::set(Portal::Businessportal24);
app(PressReleaseService::class)->depublish($pr, 'Depublizierung im Meldeverfahren '.$legalRequest->reference(), $legalRequest);
CurrentPortalContext::clear();
$entry = ArchiveEntry::query()->where('event', 'pm_depublished')->sole();
$manifest = followUpManifest($entry);
expect($entry->legal_request_id)->toBe($legalRequest->id)
->and($entry->reason_category)->toBe(ArchiveReasonCategory::ReportProcedure)
->and($manifest['legal_request']['id'])->toBe($legalRequest->id)
->and($manifest['press_release']['company']['name'])->toBe('Beispiel AG')
->and($manifest['contacts'])->toHaveCount(1);
});