presseportale/tests/Feature/Images/PressReleaseImageUploaderTest.php
Kevin Adametz 48129e99a0 audit: Nacharbeit Umsetzungsaudit 25.09.2026 — Bildfreigabe, Beweisarchiv, KI-Atomarität, Berichte (Block 8, N01–N05, N07, N08, N10–N21, 5.1, 5.2)
Bilder (N01, N05, N07, N08): gemeinsamer Freigabezustand
PressReleaseImage::isPubliclyDeliverable() für Seiten, OG und Medienroute
(Bestand bleibt sichtbar, v2 nur mit Bestätigung, Admin-Upload bis zum
Kundenklick nicht öffentlich); eigene Ability confirmImageRights nur für den
Kontoinhaber, ohne Super-Admin-Kurzschluss, Identität und ausstehende
Bestätigung auch im Service; Status-Regel unter der PM-Sperre; Cache-Flush
nach dem Commit außerhalb der Dateibereinigung; Snapshot nach Bestätigung an
veröffentlichter PM; Bestätigungstexte an die gezeigte Kennung gebunden.

Beweisarchiv (N02, N10, N11, N12, N13, 5.1): Tombstone unter Zeilensperre mit
erneuter Hold-/Fristprüfung; Dateioperationen ausgewertet, Commands mit
FAILURE; record() über .tmp + move; Marker archive_pending_at mit
archive:retry-pending und archive:sweep-orphans; Anonymisierung entfernt Bilder
über den Remover mit Antragsbezug; Draft-Purge räumt Medien nie
veröffentlichter abgelehnter Einreichungen; App-Kopien fallen mit dem
Archiv-Tombstone; label_corrected nur für veröffentlichte Bilder;
archive_entries.legal_request_id; FileDeletion-Helfer; Systemstatus-Checks.

KI und Hintergrund (N03, N04, N14, N20, N21): Ergebnis-UPDATE und Audit in
einer Transaktion; erwarteter Hash bis in reject()/holdForManualReview();
Ablaufmail per beanspruchtem Marker in einer Transaktion; strikte
Schemaprüfung der Vision-Antwort; Fortschrittsmarke ungecacht.

Berichte und Canonical (N15–N19, 5.2): lückenlose Alterskohorten;
Feed-Identität mit Portal und Sprache; Sitemap-Rolle in der Kundenansicht;
Indexverteilung auf einer Grundgesamtheit; Kopien nach Wegfall der
Erstquelle neu bewertet; Kategorie-Admin leert Seiten-Caches; Dry-Run
zählt alle Tabellen.

Gesamtsuite: 2.122 Tests grün, einer übersprungen; Larastan und Pint sauber.
Zwei Migrationen (nullable Spalten, INSTANT). N09 offen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 14:26:49 +00:00

318 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
use App\Enums\AiDeclarationSource;
use App\Enums\AiImageDeclaration;
use App\Enums\ImageProvenanceStatus;
use App\Enums\PressReleaseStatus;
use App\Models\ImageRightsConfirmation;
use App\Models\PressRelease;
use App\Models\User;
use App\Services\Image\ImageProvenanceInspector;
use App\Services\PressRelease\Images\ImageSubmission;
use App\Services\PressRelease\Images\ImageUploadActor;
use App\Services\PressRelease\Images\ImageUploadException;
use App\Services\PressRelease\Images\PressReleaseImageUploader;
use Database\Seeders\LegalDocumentSeeder;
use Database\Seeders\LegalTextSeeder;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Exceptions;
use Illuminate\Support\Facades\Storage;
use Mockery\MockInterface;
use Tests\TestCase;
beforeEach(function (): void {
/** @var TestCase $this */
Storage::fake('local');
$this->seed(LegalDocumentSeeder::class);
$this->seed(LegalTextSeeder::class);
});
function uploaderFixture(string $name): UploadedFile
{
$source = base_path('tests/Fixtures/images/'.$name);
$copy = tempnam(sys_get_temp_dir(), 'pk-upload-');
copy($source, $copy);
return new UploadedFile($copy, $name, mime_content_type($copy), null, true);
}
function uploaderSubmission(array $overrides = []): ImageSubmission
{
return new ImageSubmission(...array_merge([
'copyright' => 'Musterfirma GmbH',
'aiDeclaration' => AiImageDeclaration::Original,
'rightsConfirmed' => true,
'legalTextKey' => 'img-confirm-v2',
], $overrides));
}
function uploaderActor(PressRelease $pressRelease): ImageUploadActor
{
return ImageUploadActor::form($pressRelease->user, '203.0.113.9', 'PestBrowser/1.0');
}
test('a form upload stores original, derivatives, provenance and a confirmation bound to the same hash', function () {
$pressRelease = PressRelease::factory()->create();
$upload = uploaderFixture('camera-iptc.jpg');
$expectedHash = hash_file('sha256', $upload->getRealPath());
$image = app(PressReleaseImageUploader::class)->store(
$upload,
$pressRelease,
uploaderSubmission(['creditFromSuggestion' => true]),
uploaderActor($pressRelease),
expectedSha256: $expectedHash,
agbUrl: 'https://presseecho.test/de/agb',
);
expect($image->rules_version)->toBe(config('images.rules_version'))
->and($image->original_sha256)->toBe($expectedHash)
->and($image->original_path)->toStartWith('press-release-originals/'.$pressRelease->id.'/')
->and(Storage::disk('local')->exists($image->original_path))->toBeTrue()
->and(Storage::disk('local')->exists($image->path))->toBeTrue()
->and($image->variants)->toHaveKeys(['thumb', 'medium', 'large', 'cover', 'og'])
->and($image->path)->not->toBe($image->original_path)
->and($image->provenance_status)->toBe(ImageProvenanceStatus::UnsignedMetadata)
->and($image->provenance['derived']['credit_suggestion'])->toBe('Musterfirma GmbH / Anna Beispiel')
->and($image->credit_source?->value)->toBe('metadata')
->and($image->ai_declaration)->toBe(AiImageDeclaration::Original)
->and($image->ai_declaration_user)->toBe(AiImageDeclaration::Original)
->and($image->ai_declaration_source)->toBe(AiDeclarationSource::Customer)
->and($image->ai_declaration_locked)->toBeFalse()
->and($image->rights_confirmed_at)->not->toBeNull()
->and($image->published_ever_at)->toBeNull()
->and($image->is_preview)->toBeTrue();
$confirmation = ImageRightsConfirmation::query()->sole();
expect($confirmation->file_sha256)->toBe($expectedHash)
->and($confirmation->legal_text_key)->toBe('img-confirm-v2')
->and($confirmation->agb_version)->toBe('0.6')
->and($confirmation->user_id)->toBe($pressRelease->user_id)
->and($confirmation->company_id)->toBe($pressRelease->company_id)
->and($confirmation->channel->value)->toBe('form')
->and($confirmation->text_snapshot)->toContain('https://presseecho.test/de/agb')
->and($confirmation->ip_hmac)->toStartWith('v1:')
->and($confirmation->declaration_snapshot['provenance_status'])->toBe('unsigned_metadata')
->and($image->fresh()->hasValidRightsConfirmation())->toBeTrue();
});
test('unsigned ai metadata only prefills: a lower user choice is stored as customer choice with a deviation mark', function () {
$pressRelease = PressRelease::factory()->create();
$image = app(PressReleaseImageUploader::class)->store(
uploaderFixture('xmp-ai-generated.jpg'),
$pressRelease,
uploaderSubmission(['aiDeclaration' => AiImageDeclaration::Original]),
uploaderActor($pressRelease),
);
expect($image->ai_declaration)->toBe(AiImageDeclaration::Original)
->and($image->ai_declaration_source)->toBe(AiDeclarationSource::Customer)
->and($image->ai_declaration_locked)->toBeFalse()
->and($image->provenance['deviated_from_prefill'])->toBeTrue()
->and($image->provenance['derived']['ai_declaration_suggestion'])->toBe('ai_generated');
});
test('accepting the prefilled ai suggestion is recorded as iptc_prefill', function () {
$pressRelease = PressRelease::factory()->create();
$image = app(PressReleaseImageUploader::class)->store(
uploaderFixture('xmp-ai-generated.jpg'),
$pressRelease,
uploaderSubmission(['aiDeclaration' => AiImageDeclaration::AiGenerated]),
uploaderActor($pressRelease),
);
expect($image->ai_declaration)->toBe(AiImageDeclaration::AiGenerated)
->and($image->ai_declaration_source)->toBe(AiDeclarationSource::IptcPrefill)
->and($image->provenance)->not->toHaveKey('deviated_from_prefill');
});
test('a changed file between preflight and store is rejected and leaves no files behind', function () {
$pressRelease = PressRelease::factory()->create();
expect(fn () => app(PressReleaseImageUploader::class)->store(
uploaderFixture('plain.png'),
$pressRelease,
uploaderSubmission(),
uploaderActor($pressRelease),
expectedSha256: hash('sha256', 'andere-datei'),
))->toThrow(ImageUploadException::class);
expect(Storage::disk('local')->allFiles())->toBe([])
->and($pressRelease->images()->count())->toBe(0);
});
test('a database failure after the files were written removes original and derivatives again', function () {
$pressRelease = PressRelease::factory()->create();
DB::listen(function ($query): void {
if (str_starts_with(strtolower($query->sql), 'insert into "press_release_images"')) {
throw new RuntimeException('simulierter DB-Fehler');
}
});
expect(fn () => app(PressReleaseImageUploader::class)->store(
uploaderFixture('plain.png'),
$pressRelease,
uploaderSubmission(),
uploaderActor($pressRelease),
))->toThrow(RuntimeException::class, 'simulierter DB-Fehler');
expect(Storage::disk('local')->allFiles())->toBe([])
->and(ImageRightsConfirmation::query()->count())->toBe(0);
});
test('the one title image rule holds inside the locked transaction', function () {
$pressRelease = PressRelease::factory()->create();
$uploader = app(PressReleaseImageUploader::class);
$uploader->store(uploaderFixture('plain.png'), $pressRelease, uploaderSubmission(), uploaderActor($pressRelease));
expect(fn () => $uploader->store(uploaderFixture('camera-iptc.jpg'), $pressRelease, uploaderSubmission(), uploaderActor($pressRelease)))
->toThrow(ImageUploadException::class);
expect($pressRelease->images()->count())->toBe(1)
->and(count(Storage::disk('local')->allFiles('press-release-originals')))->toBe(1);
});
test('without the confirmation toggle nothing is stored', function () {
$pressRelease = PressRelease::factory()->create();
expect(fn () => app(PressReleaseImageUploader::class)->store(
uploaderFixture('plain.png'),
$pressRelease,
uploaderSubmission(['rightsConfirmed' => false]),
uploaderActor($pressRelease),
))->toThrow(ImageUploadException::class);
expect(Storage::disk('local')->allFiles())->toBe([]);
});
test('an outdated confirmation text key is rejected with the current key in the message', function () {
$pressRelease = PressRelease::factory()->create();
expect(fn () => app(PressReleaseImageUploader::class)->store(
uploaderFixture('plain.png'),
$pressRelease,
uploaderSubmission(['legalTextKey' => 'img-confirm-v1']),
uploaderActor($pressRelease),
))->toThrow(ImageUploadException::class, 'img-confirm-v2');
});
test('an admin upload stores no confirmation and waits for the customer', function () {
$pressRelease = PressRelease::factory()->create();
$admin = User::factory()->create();
$image = app(PressReleaseImageUploader::class)->store(
uploaderFixture('plain.png'),
$pressRelease,
uploaderSubmission(['rightsConfirmed' => false, 'legalTextKey' => null]),
ImageUploadActor::admin($admin, '203.0.113.1', 'AdminBrowser'),
);
expect(ImageRightsConfirmation::query()->count())->toBe(0)
->and($image->rights_confirmed_at)->toBeNull()
->and($image->customer_confirmation_requested_at)->not->toBeNull()
->and($image->awaitsCustomerConfirmation())->toBeTrue();
});
test('oversized uploads are refused before any processing', function () {
$pressRelease = PressRelease::factory()->create();
config()->set('images.max_pixels_per_side', 32);
expect(fn () => app(PressReleaseImageUploader::class)->store(
uploaderFixture('camera-iptc.jpg'), // 64 × 48 px
$pressRelease,
uploaderSubmission(),
uploaderActor($pressRelease),
))->toThrow(ImageUploadException::class, 'Pixel');
expect(Storage::disk('local')->allFiles())->toBe([]);
});
test('an upload to an already published press release is marked as ever published', function () {
$pressRelease = PressRelease::factory()->published()->create();
$image = app(PressReleaseImageUploader::class)->store(
uploaderFixture('plain.png'),
$pressRelease,
uploaderSubmission(),
uploaderActor($pressRelease),
);
expect($image->published_ever_at)->not->toBeNull();
});
test('a failed derivative generation never leaves the source file public', function () {
$pressRelease = PressRelease::factory()->create();
$upload = uploaderFixture('broken.jpg');
expect(fn () => app(PressReleaseImageUploader::class)->store(
$upload,
$pressRelease,
uploaderSubmission(),
uploaderActor($pressRelease),
))->toThrow(ImageUploadException::class);
expect(Storage::disk('local')->allFiles())->toBe([]);
});
test('a status change during the image check is caught under the lock and leaves no files behind', function () {
/** @var TestCase $this */
$pressRelease = PressRelease::factory()->create(['status' => PressReleaseStatus::Draft->value]);
$real = app(ImageProvenanceInspector::class);
// Die PM wird eingereicht und veröffentlicht, während die Herkunftsprüfung läuft.
$this->instance(ImageProvenanceInspector::class, Mockery::mock(ImageProvenanceInspector::class, function (MockInterface $mock) use ($real, $pressRelease): void {
$mock->shouldReceive('inspect')->andReturnUsing(function (string $path) use ($real, $pressRelease) {
DB::table('press_releases')->where('id', $pressRelease->id)->update([
'status' => PressReleaseStatus::Published->value,
'published_at' => now(),
]);
return $real->inspect($path);
});
}));
expect(fn () => app(PressReleaseImageUploader::class)->store(
uploaderFixture('plain.png'),
$pressRelease,
uploaderSubmission(),
uploaderActor($pressRelease),
allowedStatuses: [PressReleaseStatus::Draft, PressReleaseStatus::Rejected],
))->toThrow(ImageUploadException::class, 'Status der Meldung');
expect($pressRelease->images()->count())->toBe(0)
->and(Storage::disk('local')->allFiles())->toBe([])
->and(ImageRightsConfirmation::query()->count())->toBe(0);
});
test('a cache failure after the commit is reported but keeps the stored image and its files', function () {
/** @var TestCase $this */
Exceptions::fake();
$pressRelease = PressRelease::factory()->published()->create();
// Proxy auf den echten CacheManager: nur die Generations-Erhöhung des
// Seiten-Caches (WebPageCache::flush) schlägt fehl, alles andere läuft.
$cache = Mockery::mock(Cache::getFacadeRoot())->makePartial();
$cache->shouldReceive('increment')->andThrow(new RuntimeException('cache down'));
Cache::swap($cache);
$image = app(PressReleaseImageUploader::class)->store(
uploaderFixture('plain.png'),
$pressRelease,
uploaderSubmission(),
uploaderActor($pressRelease),
);
Exceptions::assertReported(fn (RuntimeException $exception): bool => $exception->getMessage() === 'cache down');
expect($pressRelease->images()->count())->toBe(1)
->and(Storage::disk('local')->exists($image->original_path))->toBeTrue()
->and(Storage::disk('local')->exists($image->path))->toBeTrue()
->and(ImageRightsConfirmation::query()->count())->toBe(1);
});