seed(RolesAndPermissionsSeeder::class); Storage::fake('archive'); Storage::fake('local'); Storage::fake('public'); Mail::fake(); config()->set('archive.key', 'base64:'.base64_encode(random_bytes(32))); }); function archiveAdmin(): User { $admin = User::factory()->create(['is_active' => true]); $admin->assignRole('admin'); return $admin; } /** * @return array{pr: PressRelease, image: PressReleaseImage} */ function archivePublishedWithImage(): 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(), 'slug' => 'archiv-'.fake()->unique()->numberBetween(1, 99999), ]); $image = PressReleaseImage::factory()->for($pr)->rulesV2()->create([ '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'); Storage::disk('local')->put('press-release-published/'.$pr->id.'/snap.json', '{"credit_line":"Foto: Anna Beispiel"}'); 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()]; } test('an entry round-trips through encryption with hash check, manifest and signed export', function () { $admin = archiveAdmin(); $pr = PressRelease::factory()->create(); $archive = app(EvidenceArchive::class); $entry = $archive->record(ArchiveEvent::PressReleaseDepublished, $pr, $pr->id, ArchiveReasonCategory::OwnDecision, 'admin', $admin->id, ['title' => 'Geheim'], ['bild.jpg' => 'jpeg-bytes'], ['files' => 1]); expect($entry)->not->toBeNull() ->and($entry->payload_path)->toStartWith('entries/'.now()->format('Y').'/') ->and($entry->retain_until->toDateString())->toBe(now()->endOfYear()->addYears(3)->toDateString()) ->and(Storage::disk('archive')->get($entry->payload_path))->not->toContain('Geheim'); $manifest = $archive->manifest($entry, $admin, 'Test'); expect($manifest['title'])->toBe('Geheim') ->and($manifest['event'])->toBe('pm_depublished'); $zip = $archive->open($entry, $admin, 'Abmahnung prüfen'); expect(hash('sha256', $zip))->toBe($entry->payload_hash); $export = $archive->export($entry, $admin, 'Anwalt'); $temp = tempnam(sys_get_temp_dir(), 'pk-test-'); file_put_contents($temp, $export); $outer = new ZipArchive; $outer->open($temp); expect($outer->getFromName('signature.txt'))->toContain('hmac-sha256-hkdf=') ->and(json_decode((string) $outer->getFromName('metadata.json'), true)['payload_sha256'])->toBe($entry->payload_hash) ->and($outer->getFromName('payload.zip'))->toBe($zip); $outer->close(); unlink($temp); expect(ArchiveAccessLog::query()->count())->toBe(3) ->and(ArchiveAccessLog::query()->where('action', 'open')->sole()->purpose)->toBe('Abmahnung prüfen'); expect(fn () => $archive->open($entry, $admin, ' '))->toThrow(RuntimeException::class); }); test('without a key nothing is archived and the business flow continues', function () { config()->set('archive.key', null); $pr = PressRelease::factory()->create(); $entry = app(EvidenceArchive::class)->record(ArchiveEvent::GdprChange, $pr, $pr->id, ArchiveReasonCategory::Gdpr, 'system', null, []); expect($entry)->toBeNull() ->and(ArchiveEntry::query()->count())->toBe(0) ->and(app(EvidenceArchive::class)->isConfigured())->toBeFalse(); }); test('entries are append-only except legal hold, retention and tombstone', function () { $admin = archiveAdmin(); $pr = PressRelease::factory()->create(); $archive = app(EvidenceArchive::class); $entry = $archive->record(ArchiveEvent::LabelCorrected, $pr, $pr->id, ArchiveReasonCategory::Correction, 'admin', $admin->id, []); expect(fn () => $entry->update(['event' => 'gdpr_change']))->toThrow(LogicException::class) ->and(fn () => $entry->delete())->toThrow(LogicException::class); // Der abgewiesene Versuch lässt das Modell schmutzig zurück — frisch laden. $entry = $entry->fresh(); $archive->setLegalHold($entry, $admin, 'Abmahnung Az. 12/2026'); expect($entry->fresh()->legal_hold)->toBeTrue() ->and($entry->fresh()->legal_hold_set_by)->toBe($admin->id); $this->travel(2)->years(); $archive->releaseLegalHold($entry->fresh(), $admin, 'Verfahren beendet'); $released = $entry->fresh(); expect($released->legal_hold)->toBeFalse() ->and($released->retain_until->toDateString())->toBe(now()->endOfYear()->addYears(3)->toDateString()); }); test('purge removes due payloads, keeps tombstones, respects legal hold, and reconcile undoes a restore', function () { /** @var TestCase $this */ $admin = archiveAdmin(); $pr = PressRelease::factory()->create(); $archive = app(EvidenceArchive::class); $due = $archive->record(ArchiveEvent::ImageRemoved, $pr, $pr->id, ArchiveReasonCategory::CustomerRequest, 'customer', null, ['x' => 1]); $held = $archive->record(ArchiveEvent::ImageRemoved, $pr, $pr->id, ArchiveReasonCategory::CustomerRequest, 'customer', null, ['x' => 2]); $archive->setLegalHold($held, $admin, 'Streit'); $duePath = $due->payload_path; $this->travelTo(now()->endOfYear()->addYears(3)->addDay()); $this->artisan('archive:purge', ['--dry-run' => true])->expectsOutputToContain('Fällig (Trockenlauf): 1')->assertSuccessful(); expect(Storage::disk('archive')->exists($duePath))->toBeTrue(); $this->artisan('archive:purge')->expectsOutputToContain('Gelöscht: 1')->assertSuccessful(); expect($due->fresh()->isTombstoned())->toBeTrue() ->and($due->fresh()->payload_path)->toBe($duePath) ->and(Storage::disk('archive')->exists($duePath))->toBeFalse() ->and($held->fresh()->isTombstoned())->toBeFalse() ->and(fn () => $archive->open($due->fresh(), $admin, 'x'))->toThrow(RuntimeException::class); $this->artisan('archive:export-tombstones')->expectsOutputToContain('1 Einträge')->assertSuccessful(); $export = json_decode((string) Storage::disk('archive')->get(config('archive.tombstone_export_path')), true); expect($export['entries'][0]['id'])->toBe($due->id); // „Restore": Sicherung bringt Zeile und Datei zurück. ArchiveEntry::withoutEvents(fn () => ArchiveEntry::query()->whereKey($due->id)->update(['deleted_at' => null])); Storage::disk('archive')->put($duePath, 'restored-cipher'); $this->artisan('archive:reconcile-after-restore')->expectsOutputToContain('1 Einträge erneut')->assertSuccessful(); expect($due->fresh()->isTombstoned())->toBeTrue() ->and(Storage::disk('archive')->exists($duePath))->toBeFalse(); }); test('removing a published image archives original, snapshot and confirmations and then drops the local evidence files', function () { ['pr' => $pr, 'image' => $image] = archivePublishedWithImage(); $admin = archiveAdmin(); app(PressReleaseImageRemover::class)->remove($image, $admin); $entry = ArchiveEntry::query()->where('event', 'image_removed')->sole(); expect($entry->subject_id)->toBe($image->id) ->and($entry->press_release_id)->toBe($pr->id) ->and($entry->actor_type)->toBe('admin') ->and($entry->summary['files'])->toBe(3) ->and(Storage::disk('local')->exists($image->original_path))->toBeFalse() ->and(Storage::disk('local')->exists($image->published_snapshot_path))->toBeFalse() ->and(PressReleaseImage::withTrashed()->whereKey($image->id)->sole()->trashed())->toBeTrue() ->and(ImageRightsConfirmation::query()->count())->toBe(1); $manifest = app(EvidenceArchive::class)->manifest($entry, $admin, 'Test'); expect($manifest['image']['copyright'])->toBe('Anna Beispiel') ->and($manifest['confirmations'])->toHaveCount(1) ->and($manifest['credit_line'])->toBe('Foto: Anna Beispiel'); $zip = new ZipArchive; $temp = tempnam(sys_get_temp_dir(), 'pk-test-'); file_put_contents($temp, app(EvidenceArchive::class)->open($entry, $admin, 'Test')); $zip->open($temp); expect($zip->getFromName('original.jpg'))->toBe('original-bytes') ->and($zip->getFromName('published.jpg'))->toBe('snapshot-bytes') ->and($zip->getFromName('published.json'))->toContain('Foto: Anna Beispiel'); $zip->close(); unlink($temp); }); test('depublication, admin removal and admin status change archive the last published state', function () { ['pr' => $pr] = archivePublishedWithImage(); $this->actingAs(archiveAdmin()); app(PressReleaseService::class)->depublish($pr); $entry = ArchiveEntry::query()->where('event', 'pm_depublished')->sole(); expect($entry->reason_category)->toBe(ArchiveReasonCategory::CustomerRequest) ->and($entry->summary['images'])->toBe(1) ->and($entry->summary['files'])->toBe(1); $manifest = app(EvidenceArchive::class)->manifest($entry, auth()->user(), 'Test'); expect($manifest['press_release']['title'])->toBe($pr->title) ->and($manifest['press_release']['company']['name'])->toBe('Beispiel AG') ->and($manifest['images'][0]['credit_line'])->toBe('Foto: Anna Beispiel'); ['pr' => $second] = archivePublishedWithImage(); $originalTitle = $second->title; app(PressReleaseService::class)->deleteFromAdmin($second); $removed = ArchiveEntry::query()->where('press_release_id', $second->id)->sole(); expect(app(EvidenceArchive::class)->manifest($removed, auth()->user(), 'Test')['press_release']['title'])->toBe($originalTitle) ->and($second->fresh()->text)->toContain('entfernt'); ['pr' => $third] = archivePublishedWithImage(); app(PressReleaseService::class)->changeStatusFromAdmin($third, PressReleaseStatus::Rejected, 'Nachträglich zurückgezogen'); expect(ArchiveEntry::query()->where('press_release_id', $third->id)->sole()->reason_category)->toBe(ArchiveReasonCategory::OwnDecision); }); test('an anonymisation archives the before and after values with the request reference', function () { ['pr' => $pr] = archivePublishedWithImage(); $pr->forceFill(['text' => '

Max Mustermann war dabei.

'])->save(); app(AnonymizationService::class)->execute($pr, ['Max Mustermann']); $entry = ArchiveEntry::query()->where('event', 'gdpr_change')->sole(); $manifest = app(EvidenceArchive::class)->manifest($entry, archiveAdmin(), 'Auskunft Art. 15'); expect($manifest['before']['text'])->toContain('Max Mustermann') ->and($manifest['after']['text'])->not->toContain('Max Mustermann') ->and($entry->reason_category)->toBe(ArchiveReasonCategory::Gdpr); }); test('a label correction is mirrored as archive entry', function () { ['image' => $image] = archivePublishedWithImage(); $admin = archiveAdmin(); app(ImageCorrectionService::class)->correctCredit($image, $admin, 'Neuer Nachweis', 'Einwand des Fotografen'); $entry = ArchiveEntry::query()->where('event', 'label_corrected')->sole(); expect($entry->summary['field'])->toBe('copyright') ->and(app(EvidenceArchive::class)->manifest($entry, $admin, 'Test')['correction']['new_value'])->toBe('Neuer Nachweis'); }); test('the admin archive page lists entries and downloads only with a purpose', function () { /** @var TestCase $this */ $admin = archiveAdmin(); $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') ->assertSuccessful() ->assertSeeText('Meldung depubliziert') ->assertSeeText('aufbewahrt'); $this->actingAs($admin)->get('https://pressekonto.test/admin/archive/'.$entry->id.'/download') ->assertSuccessful() ->assertSeeText('Zweck des Zugriffs'); expect(ArchiveAccessLog::query()->count())->toBe(0); // Zweck nur per POST (Umsetzungsaudit 29.09.2026, A-08). $this->actingAs($admin)->post('https://pressekonto.test/admin/archive/'.$entry->id.'/download', ['purpose' => 'Auskunft']) ->assertSuccessful() ->assertHeader('Content-Type', 'application/zip'); expect(ArchiveAccessLog::query()->sole()->purpose)->toBe('Auskunft'); $customer = User::factory()->create(['is_active' => true]); $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 (Quarantäne, A-03), eine alte .tmp (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 in Quarantäne, 0 entfernt')->assertSuccessful(); $this->artisan('archive:sweep-orphans')->expectsOutputToContain('2 verwaist, 1 in Quarantäne, 1 entfernt')->assertSuccessful(); expect(Storage::disk('archive')->exists('entries/2026/stale.bin'))->toBeFalse() ->and(Storage::disk('archive')->exists('quarantine/'.now()->toDateString().'/entries/2026/stale.bin'))->toBeTrue() ->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' => '

Max Mustermann war dabei.

'])->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 Nachweis nicht verfälschen: Vorher- // und Nachher-Stand stammen aus dem Auftrag (Umsetzungsaudit 29.09., A-04). $pr->forceFill(['text' => '

Ganz neuer Text.

'])->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('[entfernt] war dabei') ->and($manifest['after']['text'])->not->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(); });