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' => '
Max Mustermann war dabei.
', '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 arraySpäter bearbeitet.
', '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); });