- 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>
826 lines
41 KiB
PHP
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);
|
|
});
|