images: Lebenszyklus — Veröffentlichungsgate, published_ever_at, Snapshot der veröffentlichten Fassung, Sofortlöschung, Legacy-Bereinigung, Beweiszugriff, Korrekturweg (Bildkennzeichnung Block E)
- publish()/changeStatusFromAdmin(): kein Bild aus Formular v2 ohne gültige Bestätigung (ImageRightsConfirmationRequiredException, Admin-Seite meldet es); published_ever_at an PM und Bildern; PublishedImageSnapshot (Cover + JSON mit Nachweis/Kennzeichnung) - deleteFromAdmin() und press-releases:purge-drafts löschen Bilder nie veröffentlichter Meldungen samt Original und Bestätigung sofort (E4, O1) - press-releases:backfill-published-ever (Status-Log), press-release-images:purge-inactive-legacy (E-B11, Trockenlauf Standard) - Admin-Route für Beweisoriginal und veröffentlichte Fassung nur mit Zweck, ActivityType EvidenceAccess; Architekturtest: original_path nur in vier Dateien - ImageCorrectionService + image_declaration_corrections (insert-only), withheld_at blendet das Bild öffentlich aus (publiclyVisible-Scope), Korrekturpanel auf der Admin-Seite - Systemstatus: fehlgeschlagene Herkunftsprüfungen der letzten 7 Tage Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
9f44546082
commit
da167b713b
24 changed files with 1202 additions and 12 deletions
376
tests/Feature/Images/ImageLifecycleTest.php
Normal file
376
tests/Feature/Images/ImageLifecycleTest.php
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\ActivityType;
|
||||
use App\Enums\AiDeclarationSource;
|
||||
use App\Enums\AiImageDeclaration;
|
||||
use App\Enums\ConfirmationChannel;
|
||||
use App\Enums\Portal;
|
||||
use App\Enums\PressReleaseStatus;
|
||||
use App\Models\ActivityEvent;
|
||||
use App\Models\Category;
|
||||
use App\Models\Company;
|
||||
use App\Models\ImageDeclarationCorrection;
|
||||
use App\Models\ImageRightsConfirmation;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseImage;
|
||||
use App\Models\PressReleaseStatusLog;
|
||||
use App\Models\User;
|
||||
use App\Services\Admin\AdminSystemHealth;
|
||||
use App\Services\PressRelease\ImageRightsConfirmationRequiredException;
|
||||
use App\Services\PressRelease\Images\ImageCorrectionService;
|
||||
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 Livewire\Volt\Volt as LivewireVolt;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Bildkennzeichnung Block E: Veröffentlichungsgate, published_ever_at,
|
||||
* Snapshot, Sofortlöschung, Backfill, Legacy-Bereinigung, Beweiszugriff,
|
||||
* Korrekturweg, Systemstatus.
|
||||
*/
|
||||
beforeEach(function (): void {
|
||||
/** @var TestCase $this */
|
||||
$this->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',
|
||||
];
|
||||
|
||||
$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');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue