seed(RolesAndPermissionsSeeder::class); Storage::fake('local'); Storage::fake('public'); Mail::fake(); }); function lifecycleAdmin(): User { $admin = User::factory()->create(['is_active' => true]); $admin->assignRole('admin'); return $admin; } /** * @return array{author: User, company: Company, pr: PressRelease} */ function lifecycleRelease(string $status = 'review'): array { $author = User::factory()->create(['is_active' => true]); $author->assignRole('customer'); $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()->forPortal(Portal::Presseecho)->for($category)->for($company)->create([ 'user_id' => $author->id, 'status' => $status, 'slug' => 'lifecycle-'.fake()->unique()->numberBetween(1, 99999), ]); return compact('author', 'company', 'pr'); } function lifecycleImage(PressRelease $pr, bool $confirmed = true, array $attributes = []): PressReleaseImage { $image = PressReleaseImage::factory()->for($pr)->rulesV2()->create($attributes); Storage::disk('local')->put($image->path, 'cover-bytes-'.$image->id); Storage::disk('local')->put($image->original_path, 'original-bytes-'.$image->id); if ($confirmed) { 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 $image->fresh(); } test('publishing marks release and images as ever published and captures the snapshot', function () { ['pr' => $pr] = lifecycleRelease(); $image = lifecycleImage($pr); app(PressReleaseService::class)->publish($pr); $pr->refresh(); $image->refresh(); expect($pr->status)->toBe(PressReleaseStatus::Published) ->and($pr->published_ever_at)->not->toBeNull() ->and($image->published_ever_at)->not->toBeNull() ->and($image->published_snapshot_path)->toStartWith('press-release-published/'.$pr->id.'/') ->and(Storage::disk('local')->get($image->published_snapshot_path))->toBe('cover-bytes-'.$image->id) ->and($image->published_snapshot_sha256)->toBe(hash('sha256', 'cover-bytes-'.$image->id)); $sidecar = json_decode((string) Storage::disk('local')->get(preg_replace('/\.jpg$/', '.json', $image->published_snapshot_path)), true); expect($sidecar['credit_line'])->toContain('Foto: ') ->and($sidecar['confirmation_key'])->toBe('img-confirm-v2') ->and($sidecar['original_sha256'])->toBe($image->original_sha256); }); test('publishing is blocked while a formular v2 image lacks a valid confirmation', function () { ['pr' => $pr] = lifecycleRelease(); lifecycleImage($pr, confirmed: false, attributes: ['customer_confirmation_requested_at' => now()]); expect(fn () => app(PressReleaseService::class)->publish($pr)) ->toThrow(ImageRightsConfirmationRequiredException::class); expect($pr->fresh()->status)->toBe(PressReleaseStatus::Review) ->and($pr->fresh()->published_ever_at)->toBeNull(); }); test('a legacy image never blocks publishing', function () { ['pr' => $pr] = lifecycleRelease(); PressReleaseImage::factory()->for($pr)->legacy()->create(); app(PressReleaseService::class)->publish($pr); expect($pr->fresh()->status)->toBe(PressReleaseStatus::Published); }); test('the admin status change to published applies the same gate and marks images', function () { ['pr' => $pr] = lifecycleRelease('draft'); $image = lifecycleImage($pr, confirmed: false); expect(fn () => app(PressReleaseService::class)->changeStatusFromAdmin($pr, PressReleaseStatus::Published)) ->toThrow(ImageRightsConfirmationRequiredException::class); ['pr' => $pr2] = lifecycleRelease('draft'); $image2 = lifecycleImage($pr2); app(PressReleaseService::class)->changeStatusFromAdmin($pr2, PressReleaseStatus::Published); expect($pr2->fresh()->published_ever_at)->not->toBeNull() ->and($image2->fresh()->published_ever_at)->not->toBeNull() ->and($image2->fresh()->published_snapshot_path)->not->toBeNull() ->and($image->fresh()->published_ever_at)->toBeNull(); }); test('the admin page reports the gate instead of crashing', function () { /** @var TestCase $this */ ['pr' => $pr] = lifecycleRelease(); lifecycleImage($pr, confirmed: false); $this->actingAs(lifecycleAdmin()); LivewireVolt::test('admin.press-releases.show', ['id' => $pr->id]) ->call('publish') ->assertHasNoErrors(); expect($pr->fresh()->status)->toBe(PressReleaseStatus::Review); }); test('deleting a never published release from the admin removes images, originals and confirmations for good', function () { ['pr' => $pr] = lifecycleRelease('draft'); $image = lifecycleImage($pr); app(PressReleaseService::class)->deleteFromAdmin($pr); expect(PressReleaseImage::withTrashed()->whereKey($image->id)->exists())->toBeFalse() ->and(ImageRightsConfirmation::query()->count())->toBe(0) ->and(Storage::disk('local')->allFiles())->toBe([]) ->and(PressRelease::withTrashed()->find($pr->id)?->trashed())->toBeTrue(); }); test('the draft purge deletes images of stale never published drafts immediately', function () { /** @var TestCase $this */ ['pr' => $pr] = lifecycleRelease('draft'); $image = lifecycleImage($pr); DB::table('press_releases')->where('id', $pr->id)->update(['updated_at' => now()->subDays(200)]); ['pr' => $fresh] = lifecycleRelease('draft'); $freshImage = lifecycleImage($fresh); $this->artisan('press-releases:purge-drafts', ['--days' => 180]) ->expectsOutputToContain('Bilder gelöscht: 1') ->assertSuccessful(); expect(PressReleaseImage::withTrashed()->whereKey($image->id)->exists())->toBeFalse() ->and(Storage::disk('local')->exists($image->original_path))->toBeFalse() ->and($pr->fresh()->status)->toBe(PressReleaseStatus::Archived) ->and(PressReleaseImage::query()->whereKey($freshImage->id)->exists())->toBeTrue(); }); test('the published-ever backfill derives the date from the status log and lets images inherit it', function () { /** @var TestCase $this */ ['pr' => $pr] = lifecycleRelease('archived'); $pr->forceFill(['published_at' => null, 'published_ever_at' => null])->save(); PressReleaseStatusLog::query()->create([ 'press_release_id' => $pr->id, 'from_status' => 'review', 'to_status' => 'published', 'source' => 'admin', 'created_at' => now()->subDays(30), ]); $image = PressReleaseImage::factory()->for($pr)->legacy()->create(); ['pr' => $neverPublished] = lifecycleRelease('rejected'); $neverPublished->forceFill(['published_at' => null])->save(); $this->artisan('press-releases:backfill-published-ever', ['--dry-run' => true]) ->expectsOutputToContain('die öffentlich waren: 1') ->assertSuccessful(); $this->artisan('press-releases:backfill-published-ever')->assertSuccessful(); expect($pr->fresh()->published_ever_at?->toDateString())->toBe(now()->subDays(30)->toDateString()) ->and($image->fresh()->published_ever_at)->not->toBeNull() ->and($neverPublished->fresh()->published_ever_at)->toBeNull(); }); test('the legacy purge removes only legacy images of inactive releases and reports per status', function () { /** @var TestCase $this */ ['pr' => $rejected] = lifecycleRelease('rejected'); ['pr' => $published] = lifecycleRelease('published'); ['pr' => $draftV2] = lifecycleRelease('draft'); $legacyGone = PressReleaseImage::factory()->for($rejected)->legacy()->create(['disk' => 'local', 'path' => 'presseecho/press/alt.jpg']); Storage::disk('local')->put('presseecho/press/alt.jpg', 'alt'); $legacyStays = PressReleaseImage::factory()->for($published)->legacy()->create(); $v2Stays = lifecycleImage($draftV2); $this->artisan('press-release-images:purge-inactive-legacy') ->expectsOutputToContain('Trockenlauf: Legacy-Bilder an inaktiven Meldungen: 1') ->assertSuccessful(); expect(PressReleaseImage::query()->count())->toBe(3); $this->artisan('press-release-images:purge-inactive-legacy', ['--execute' => true]) ->expectsOutputToContain('Gelöscht: 1 Bilder') ->assertSuccessful(); expect(PressReleaseImage::withTrashed()->whereKey($legacyGone->id)->exists())->toBeFalse() ->and(Storage::disk('local')->exists('presseecho/press/alt.jpg'))->toBeFalse() ->and(PressReleaseImage::query()->whereKey($legacyStays->id)->exists())->toBeTrue() ->and(PressReleaseImage::query()->whereKey($v2Stays->id)->exists())->toBeTrue(); }); test('the evidence original is only reachable by admins with a purpose and every access is logged', function () { /** @var TestCase $this */ ['pr' => $pr, 'author' => $author] = lifecycleRelease('published'); $image = lifecycleImage($pr); $admin = lifecycleAdmin(); $url = route('admin.press-releases.images.original', ['id' => $pr->id, 'image' => $image->id], absolute: false); $this->get('https://pressekonto.test'.$url.'?purpose=Abmahnung+pr%C3%BCfen')->assertRedirect(); $this->actingAs($author)->get('https://pressekonto.test'.$url.'?purpose=x')->assertStatus(403); $this->actingAs($admin)->get('https://pressekonto.test'.$url)->assertStatus(422); $response = $this->actingAs($admin)->get('https://pressekonto.test'.$url.'?purpose=Abmahnung+pr%C3%BCfen'); $response->assertSuccessful()->assertHeader('Cache-Control', 'no-store, private'); expect($response->streamedContent())->toBe('original-bytes-'.$image->id); $event = ActivityEvent::query()->where('type', ActivityType::EvidenceAccess->value)->sole(); expect($event->description)->toContain('Abmahnung prüfen') ->and($event->subject_id)->toBe($image->id); // Die Medienroute kennt das Original nicht: „original" ist dort das Cover. $media = $this->get(route('media.press-release-images.show', ['image' => $image->id, 'variant' => 'original'])); $media->assertSuccessful(); expect($media->streamedContent())->toBe('cover-bytes-'.$image->id); }); test('the evidence original path never leaks outside the allowed files', function () { $allowed = [ 'app/Models/PressReleaseImage.php', 'app/Services/PressRelease/Images/PressReleaseImageUploader.php', 'app/Services/PressRelease/Images/PressReleaseImageRemover.php', 'app/Http/Controllers/Admin/PressReleaseImageEvidenceController.php', 'app/Services/Archive/PressReleaseArchiver.php', ]; $offenders = collect(array_merge( glob(base_path('app/**/*.php')) ?: [], glob(base_path('app/**/**/*.php')) ?: [], glob(base_path('app/**/**/**/*.php')) ?: [], glob(base_path('resources/views/**/*.blade.php')) ?: [], glob(base_path('resources/views/**/**/*.blade.php')) ?: [], glob(base_path('resources/views/**/**/**/*.blade.php')) ?: [], )) ->unique() ->filter(fn (string $file): bool => str_contains((string) file_get_contents($file), 'original_path')) ->map(fn (string $file): string => str_replace(base_path().'/', '', $file)) ->reject(fn (string $file): bool => in_array($file, $allowed, true)) ->values() ->all(); expect($offenders)->toBe([]); }); test('corrections are recorded with reason and source, and withholding hides the image publicly', function () { /** @var TestCase $this */ ['pr' => $pr] = lifecycleRelease(); $image = lifecycleImage($pr, attributes: ['copyright' => 'Falscher Nachweis', 'ai_declaration' => 'original', 'ai_declaration_user' => 'original']); app(PressReleaseService::class)->publish($pr); $admin = lifecycleAdmin(); $service = app(ImageCorrectionService::class); $service->correctCredit($image, $admin, 'Richtiger Nachweis', 'Einwand des Fotografen per Mail vom 22.09.'); $service->correctAiDeclaration($image, $admin, AiImageDeclaration::AiGenerated, 'Herkunftsdaten nachgereicht'); $image->refresh(); expect($image->copyright)->toBe('Richtiger Nachweis') ->and($image->ai_declaration)->toBe(AiImageDeclaration::AiGenerated) ->and($image->ai_declaration_user)->toBe(AiImageDeclaration::Original) ->and($image->ai_declaration_source)->toBe(AiDeclarationSource::AdminCorrection) ->and(ImageDeclarationCorrection::query()->count())->toBe(2) ->and(ImageDeclarationCorrection::query()->where('field', 'ai_declaration')->sole()->old_source)->toBe('customer') ->and(ImageRightsConfirmation::query()->count())->toBe(1); $this->get('https://presseecho.test/de/press-release/'.$pr->slug) ->assertSuccessful() ->assertSeeText('Foto: Richtiger Nachweis · KI-generiert') ->assertSeeText('Kennzeichnung: vom Portal nach Prüfung korrigiert'); $service->withhold($image, $admin, 'Einwand wird geprüft'); $this->get('https://presseecho.test/de/press-release/'.$pr->slug) ->assertSuccessful() ->assertDontSee('data-image-credit', false) ->assertDontSeeText('Richtiger Nachweis'); $service->release($image->fresh(), $admin, 'Einwand unbegründet'); $this->get('https://presseecho.test/de/press-release/'.$pr->slug)->assertSeeText('Foto: Richtiger Nachweis'); expect(fn () => ImageDeclarationCorrection::query()->first()->delete())->toThrow(LogicException::class); }); test('the admin page applies a correction with a mandatory reason', function () { /** @var TestCase $this */ ['pr' => $pr] = lifecycleRelease('published'); $image = lifecycleImage($pr); $this->actingAs(lifecycleAdmin()); LivewireVolt::test('admin.press-releases.show', ['id' => $pr->id]) ->assertSee('Bildnachweis und Kennzeichnung korrigieren') ->set('correctionAiDeclaration', 'ai_edited') ->call('correctImage') ->assertHasErrors(['correctionReason']) ->set('correctionReason', 'Kunde hat KI-Retusche bestätigt') ->call('correctImage') ->assertHasNoErrors(); expect($image->fresh()->ai_declaration)->toBe(AiImageDeclaration::AiEdited) ->and(ImageDeclarationCorrection::query()->count())->toBe(1); }); test('the system health warns about failed provenance checks of the last seven days', function () { ['pr' => $pr] = lifecycleRelease('draft'); PressReleaseImage::factory()->for($pr)->rulesV2()->create(['provenance_status' => 'error', 'provenance_checked_at' => now()->subDay()]); $checks = collect(app(AdminSystemHealth::class)->snapshot()['checks'])->keyBy('key'); expect($checks->get('image_provenance')['status'])->toBe('warn') ->and($checks->get('image_provenance')['detail'])->toContain('1 Bild-Upload'); }); test('the media route applies the shared delivery rule: legacy stays public, withheld and unconfirmed v2 images do not', function () { /** @var TestCase $this */ ['pr' => $pr, 'author' => $author] = lifecycleRelease('published'); $pr->forceFill(['published_at' => now()->subDay(), 'published_ever_at' => now()->subDay()])->save(); $admin = lifecycleAdmin(); $legacy = PressReleaseImage::factory()->for($pr)->legacy()->create(['disk' => 'local', 'path' => 'presseecho/press/legacy.jpg']); Storage::disk('local')->put($legacy->path, 'legacy-bytes'); $withheld = lifecycleImage($pr, attributes: ['withheld_at' => now()]); $unconfirmed = lifecycleImage($pr, confirmed: false, attributes: ['rights_confirmed_at' => null, 'customer_confirmation_requested_at' => now()]); $confirmed = lifecycleImage($pr); $url = fn (PressReleaseImage $image): string => route('media.press-release-images.show', ['image' => $image->id]); expect($legacy->isPubliclyDeliverable())->toBeTrue() ->and($withheld->isPubliclyDeliverable())->toBeFalse() ->and($unconfirmed->isPubliclyDeliverable())->toBeFalse() ->and($confirmed->isPubliclyDeliverable())->toBeTrue() ->and(PressReleaseImage::query()->publiclyVisible()->pluck('id')->all())->toEqualCanonicalizing([$legacy->id, $confirmed->id]); $this->get($url($legacy))->assertSuccessful()->assertHeader('Cache-Control', 'max-age=86400, public'); $this->get($url($confirmed))->assertSuccessful()->assertHeader('Cache-Control', 'max-age=86400, public'); $this->get($url($withheld))->assertNotFound(); $this->get($url($unconfirmed))->assertNotFound(); // Berechtigte interne Ansicht bleibt, aber ohne öffentlichen Cache. $this->actingAs($author)->get($url($withheld))->assertSuccessful()->assertHeader('Cache-Control', 'no-store, private'); $this->actingAs($admin)->get($url($unconfirmed))->assertSuccessful()->assertHeader('Cache-Control', 'no-store, private'); app(ImageCorrectionService::class)->release($withheld, $admin, 'Einwand geprüft'); auth()->logout(); $this->get($url($withheld))->assertSuccessful()->assertHeader('Cache-Control', 'max-age=86400, public'); }); // --------------------------------------------------------------------------- // Umsetzungsaudit 25.09.2026: N13, 5.1 b, 5.1 e // --------------------------------------------------------------------------- test('the draft purge also cleans media of stale rejected submissions that were never published and keeps their status', function () { /** @var TestCase $this */ ['pr' => $stale] = lifecycleRelease('rejected'); $staleImage = lifecycleImage($stale); DB::table('press_releases')->where('id', $stale->id)->update(['updated_at' => now()->subDays(200)]); ['pr' => $fresh] = lifecycleRelease('rejected'); $freshImage = lifecycleImage($fresh); // Altbestand: im Altsystem „offline genommen", war einmal veröffentlicht. ['pr' => $formerlyPublished] = lifecycleRelease('rejected'); $formerlyPublished->forceFill(['published_ever_at' => now()->subYear()])->save(); $keptImage = lifecycleImage($formerlyPublished, true, ['published_ever_at' => now()->subYear()]); DB::table('press_releases')->where('id', $formerlyPublished->id)->update(['updated_at' => now()->subDays(400)]); $this->artisan('press-releases:purge-drafts', ['--days' => 180, '--dry-run' => true]) ->expectsOutputToContain('1 abgelehnte Einreichungen würden bereinigt') ->assertSuccessful(); expect(PressReleaseImage::query()->whereKey($staleImage->id)->exists())->toBeTrue(); $this->artisan('press-releases:purge-drafts', ['--days' => 180]) ->expectsOutputToContain('abgelehnte Einreichungen bereinigt: 1, Bilder gelöscht: 1') ->assertSuccessful(); expect(PressReleaseImage::withTrashed()->whereKey($staleImage->id)->exists())->toBeFalse() ->and(Storage::disk('local')->exists($staleImage->original_path))->toBeFalse() ->and(ImageRightsConfirmation::query()->where('subject_id', $staleImage->id)->count())->toBe(0) ->and($stale->fresh()->status)->toBe(PressReleaseStatus::Rejected) ->and(PressReleaseImage::query()->whereKey($freshImage->id)->exists())->toBeTrue() ->and(PressReleaseImage::query()->whereKey($keptImage->id)->exists())->toBeTrue() ->and(Storage::disk('local')->exists($keptImage->original_path))->toBeTrue(); // Wiederholungsfest: ohne Bilder zählt die abgelehnte Meldung nicht mehr. $this->artisan('press-releases:purge-drafts', ['--days' => 180]) ->expectsOutputToContain('abgelehnte Einreichungen bereinigt: 0, Bilder gelöscht: 0') ->assertSuccessful(); }); test('a correction of a never published image is logged but not archived', function () { Storage::fake('archive'); config()->set('archive.key', 'base64:'.base64_encode(random_bytes(32))); ['pr' => $pr] = lifecycleRelease('draft'); $image = lifecycleImage($pr); app(ImageCorrectionService::class)->correctCredit($image, lifecycleAdmin(), 'Neuer Nachweis', 'Tippfehler'); expect(ImageDeclarationCorrection::query()->where('press_release_image_id', $image->id)->count())->toBe(1) ->and(ArchiveEntry::query()->count())->toBe(0); }); test('the immediate deletion keeps the image row when a local file cannot be deleted, and the health check counts leftovers', function () { ['pr' => $pr] = lifecycleRelease('draft'); $image = lifecycleImage($pr); FailingDisk::install('local', ['delete' => fn () => false]); expect(fn () => app(PressReleaseImageRemover::class)->remove($image)) ->toThrow(RuntimeException::class); FailingDisk::restore('local'); expect(PressReleaseImage::query()->whereKey($image->id)->exists())->toBeTrue() ->and(ImageRightsConfirmation::query()->where('subject_id', $image->id)->count())->toBe(1) ->and(Storage::disk('local')->exists($image->original_path))->toBeTrue(); // Entferntes v2-Bild mit noch vorhandenem Original: Systemstatus warnt. $checks = collect(app(AdminSystemHealth::class)->snapshot()['checks'])->keyBy('key'); expect($checks->get('image_evidence_leftovers')['status'])->toBe('ok'); $image->forceFill(['published_ever_at' => now()])->save(); $image->delete(); $checks = collect(app(AdminSystemHealth::class)->snapshot()['checks'])->keyBy('key'); expect($checks->get('image_evidence_leftovers')['status'])->toBe('warn') ->and($checks->get('image_evidence_leftovers')['detail'])->toContain('1 entfernte(s) Bild(er)'); });