- press_release_images: Kundenangabe/Portalkennzeichnung getrennt, Herkunftsbefund, Beweisoriginal, Snapshot-Pfade, published_ever_at, rules_version, Fokus/Letterbox; Bestand unangetastet - legal_texts (insert-only) mit img-confirm-v1 (alter Wortlaut), v2 (AGB 0.6), v3 (AGB 0.7, noch ohne Datum); image-rights:backfill-confirmations - image_rights_confirmations polymorph, insert-only, IP als versionierter HMAC; Löschung nur für nie veröffentlichte Uploads - ImageProvenanceInspector (Imagick/exif): IPTC, XMP, EXIF; algorithmicallyEnhanced nur Vermerk; unlesbare Datei = error - PressReleaseImageUploader: Original vor Derivaten, Hash-Bindung, Titelbild-Regel unter Sperre, Dateien bei Fehlern zurückgerollt; Admin-Upload ohne Bestätigung - ImageService: kein Rückfall auf die Quelldatei, og-Variante, Fokuspunkt und no_crop Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
257 lines
10 KiB
PHP
257 lines
10 KiB
PHP
<?php
|
||
|
||
use App\Enums\AiDeclarationSource;
|
||
use App\Enums\AiImageDeclaration;
|
||
use App\Enums\ImageProvenanceStatus;
|
||
use App\Models\ImageRightsConfirmation;
|
||
use App\Models\PressRelease;
|
||
use App\Models\User;
|
||
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\DB;
|
||
use Illuminate\Support\Facades\Storage;
|
||
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([]);
|
||
});
|