presseportale/tests/Feature/Archive/EvidenceArchiveTest.php
Kevin Adametz 7cd84b2be3 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

577 lines
29 KiB
PHP

<?php
use App\Enums\ActivityType;
use App\Enums\ArchiveEvent;
use App\Enums\ArchiveReasonCategory;
use App\Enums\ConfirmationChannel;
use App\Enums\LegalRequestType;
use App\Enums\Portal;
use App\Enums\PressReleaseStatus;
use App\Models\ActivityEvent;
use App\Models\ArchiveAccessLog;
use App\Models\ArchiveEntry;
use App\Models\Category;
use App\Models\Company;
use App\Models\ImageDeclarationCorrection;
use App\Models\ImageRightsConfirmation;
use App\Models\LegalRequest;
use App\Models\PressRelease;
use App\Models\PressReleaseImage;
use App\Models\PressReleaseRedactionSnapshot;
use App\Models\User;
use App\Services\Admin\AdminSystemHealth;
use App\Services\Archive\EvidenceArchive;
use App\Services\PressRelease\AnonymizationService;
use App\Services\PressRelease\Images\ImageCorrectionService;
use App\Services\PressRelease\Images\PressReleaseImageRemover;
use App\Services\PressRelease\PressReleaseService;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Tests\Support\FailingDisk;
use Tests\TestCase;
/**
* Beweisarchiv (Bildkennzeichnung Block F): Verschlüsselung, Append-only,
* Legal Hold, Purge mit Tombstone, Tombstone-Export und Restore-Abgleich,
* Auslöser (Bild entfernt, Depublikation, DSGVO-Änderung, Korrektur),
* Zugriffsprotokoll und Admin-Zugriff.
*/
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)));
});
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' => '<p>Max Mustermann war dabei.</p>'])->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' => '<p>Max Mustermann war dabei.</p>'])->save();
$legalRequest = LegalRequest::factory()->type(LegalRequestType::Dsgvo)->create(['press_release_id' => $pr->id]);
$this->actingAs(archiveAdmin());
FailingDisk::install('archive', ['put' => fn () => false]);
app(AnonymizationService::class)->execute($pr, ['Max Mustermann'], removeImageIds: [$image->id], legalRequest: $legalRequest);
FailingDisk::restore('archive');
expect($pr->fresh()->text)->not->toContain('Max Mustermann')
->and($pr->fresh()->archive_pending_at)->not->toBeNull()
->and(PressReleaseImage::withTrashed()->whereKey($image->id)->sole()->trashed())->toBeTrue()
->and(Storage::disk('local')->exists($image->path))->toBeFalse()
->and(Storage::disk('local')->exists($image->original_path))->toBeTrue()
->and(ArchiveEntry::query()->count())->toBe(0);
// Nachträgliche Änderung darf den Nachweis nicht verfälschen: Vorher-
// und Nachher-Stand stammen aus dem Auftrag (Umsetzungsaudit 29.09., A-04).
$pr->forceFill(['text' => '<p>Ganz neuer Text.</p>'])->save();
$this->artisan('archive:retry-pending')->expectsOutputToContain('Nachgeholt: 2')->assertSuccessful();
$change = ArchiveEntry::query()->where('event', 'gdpr_change')->sole();
$removed = ArchiveEntry::query()->where('event', 'image_removed')->sole();
$manifest = app(EvidenceArchive::class)->manifest($change, auth()->user(), 'Test');
expect($change->legal_request_id)->toBe($legalRequest->id)
->and($removed->legal_request_id)->toBe($legalRequest->id)
->and($removed->reason_category)->toBe(ArchiveReasonCategory::Gdpr)
->and($manifest['before']['text'])->toContain('Max Mustermann')
->and($manifest['after']['text'])->toContain('[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();
});