images: Datenmodell, Textarchiv, Herkunftsprüfung und gemeinsamer Uploader für Formular v2 (Bildkennzeichnung Block A)
- 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>
This commit is contained in:
parent
b624f80d7f
commit
e9bdff3ad3
36 changed files with 2631 additions and 30 deletions
77
tests/Feature/Images/ImageProvenanceInspectorTest.php
Normal file
77
tests/Feature/Images/ImageProvenanceInspectorTest.php
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\ImageProvenanceStatus;
|
||||
use App\Services\Image\ImageProvenanceInspector;
|
||||
|
||||
/**
|
||||
* Bildkennzeichnung A.5: echte Fixture-Dateien (tests/Fixtures/images,
|
||||
* erzeugt mit generate-fixtures.php), keine Fake-Bilder.
|
||||
*/
|
||||
function provenanceFixture(string $name): string
|
||||
{
|
||||
return base_path('tests/Fixtures/images/'.$name);
|
||||
}
|
||||
|
||||
test('a camera jpeg with classic iptc yields an unsigned credit suggestion and no ai suggestion', function () {
|
||||
$result = (new ImageProvenanceInspector)->inspect(provenanceFixture('camera-iptc.jpg'));
|
||||
|
||||
expect($result->status)->toBe(ImageProvenanceStatus::UnsignedMetadata)
|
||||
->and($result->creditSuggestion())->toBe('Musterfirma GmbH / Anna Beispiel')
|
||||
->and($result->derived['credit_suggestion_source'])->toBe('credit')
|
||||
->and($result->suggestedAiDeclaration())->toBeNull()
|
||||
->and($result->enforcedAiDeclaration())->toBeNull()
|
||||
->and($result->iptc['byline'])->toBe('Anna Beispiel')
|
||||
->and($result->iptc['copyright_notice'])->toBe('© 2026 Musterfirma GmbH')
|
||||
->and($result->fileSha256)->toBe(hash_file('sha256', provenanceFixture('camera-iptc.jpg')));
|
||||
});
|
||||
|
||||
test('xmp trainedAlgorithmicMedia as full iptc uri suggests ai_generated but never locks in phase one', function () {
|
||||
$result = (new ImageProvenanceInspector)->inspect(provenanceFixture('xmp-ai-generated.jpg'));
|
||||
|
||||
expect($result->status)->toBe(ImageProvenanceStatus::UnsignedMetadata)
|
||||
->and($result->iptc['digital_source_type'])->toBe('trainedAlgorithmicMedia')
|
||||
->and($result->suggestedAiDeclaration()?->value)->toBe('ai_generated')
|
||||
->and($result->enforcedAiDeclaration())->toBeNull()
|
||||
->and($result->creditSuggestion())->toBe('KI-Bild / Musterfirma');
|
||||
});
|
||||
|
||||
test('algorithmicallyEnhanced only sets the enhancement hint and takes the creator list as credit', function () {
|
||||
$result = (new ImageProvenanceInspector)->inspect(provenanceFixture('xmp-enhanced.jpg'));
|
||||
|
||||
expect($result->suggestedAiDeclaration())->toBeNull()
|
||||
->and($result->derived['enhancement_hint'])->toBeTrue()
|
||||
->and($result->creditSuggestion())->toBe('Max Mustermann')
|
||||
->and($result->derived['credit_suggestion_source'])->toBe('creator');
|
||||
});
|
||||
|
||||
test('a png without metadata is absent, not an error', function () {
|
||||
$result = (new ImageProvenanceInspector)->inspect(provenanceFixture('plain.png'));
|
||||
|
||||
expect($result->status)->toBe(ImageProvenanceStatus::Absent)
|
||||
->and($result->creditSuggestion())->toBeNull()
|
||||
->and($result->error)->toBeNull();
|
||||
});
|
||||
|
||||
test('webp xmp digitalCapture suggests original and falls back to the copyright notice for the credit', function () {
|
||||
$result = (new ImageProvenanceInspector)->inspect(provenanceFixture('webp-capture.webp'));
|
||||
|
||||
expect($result->status)->toBe(ImageProvenanceStatus::UnsignedMetadata)
|
||||
->and($result->suggestedAiDeclaration()?->value)->toBe('original')
|
||||
->and($result->creditSuggestion())->toBe('© Beispiel AG')
|
||||
->and($result->derived['credit_suggestion_source'])->toBe('copyright_notice');
|
||||
});
|
||||
|
||||
test('an unreadable file reports error instead of pretending there is no metadata', function () {
|
||||
$result = (new ImageProvenanceInspector)->inspect(provenanceFixture('broken.jpg'));
|
||||
|
||||
expect($result->status)->toBe(ImageProvenanceStatus::Error)
|
||||
->and($result->error)->not->toBeNull()
|
||||
->and($result->toArray()['error']['class'])->toBe('ImagickException');
|
||||
});
|
||||
|
||||
test('the result serialises with status, tool and duration for the image row', function () {
|
||||
$array = (new ImageProvenanceInspector)->inspect(provenanceFixture('camera-iptc.jpg'))->toArray();
|
||||
|
||||
expect($array)->toHaveKeys(['status', 'file_sha256', 'tool', 'duration_ms', 'iptc', 'exif', 'derived'])
|
||||
->and($array['tool'])->toStartWith('imagick ');
|
||||
});
|
||||
101
tests/Feature/Images/ImageRightsConfirmationTest.php
Normal file
101
tests/Feature/Images/ImageRightsConfirmationTest.php
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\ConfirmationChannel;
|
||||
use App\Models\ImageRightsConfirmation;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseImage;
|
||||
|
||||
function makeConfirmationFor(PressReleaseImage $image): ImageRightsConfirmation
|
||||
{
|
||||
return ImageRightsConfirmation::query()->create([
|
||||
'subject_type' => $image->getMorphClass(),
|
||||
'subject_id' => $image->getKey(),
|
||||
'press_release_id' => $image->press_release_id,
|
||||
'company_id' => $image->pressRelease->company_id,
|
||||
'user_id' => $image->pressRelease->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' => ['copyright' => $image->copyright],
|
||||
'ip_hmac' => ImageRightsConfirmation::hashIp('203.0.113.7'),
|
||||
'user_agent' => 'PestBrowser/1.0',
|
||||
'confirmed_at' => now(),
|
||||
'created_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
test('confirmations are insert-only', function () {
|
||||
$image = PressReleaseImage::factory()->rulesV2()->create();
|
||||
$confirmation = makeConfirmationFor($image);
|
||||
|
||||
expect(fn () => $confirmation->update(['agb_version' => '0.7']))->toThrow(LogicException::class)
|
||||
->and(fn () => $confirmation->delete())->toThrow(LogicException::class)
|
||||
->and(ImageRightsConfirmation::query()->count())->toBe(1);
|
||||
});
|
||||
|
||||
test('only the purge for never published uploads may remove a confirmation', function () {
|
||||
$image = PressReleaseImage::factory()->rulesV2()->create();
|
||||
$confirmation = makeConfirmationFor($image);
|
||||
|
||||
$confirmation->purgeForUnpublished();
|
||||
|
||||
expect(ImageRightsConfirmation::query()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test('a hard deleted image leaves its confirmation with the snapshot in place', function () {
|
||||
$image = PressReleaseImage::factory()->rulesV2()->create();
|
||||
$confirmation = makeConfirmationFor($image);
|
||||
$pressReleaseId = $image->press_release_id;
|
||||
|
||||
$image->forceDelete();
|
||||
|
||||
$confirmation->refresh();
|
||||
|
||||
expect($confirmation->subject_id)->toBe($image->getKey())
|
||||
->and($confirmation->press_release_id)->toBe($pressReleaseId)
|
||||
->and($confirmation->file_sha256)->toBe($image->original_sha256)
|
||||
->and($confirmation->declaration_snapshot['copyright'])->toBe($image->copyright);
|
||||
});
|
||||
|
||||
test('a hard deleted press release nulls the reference but keeps the row', function () {
|
||||
$pressRelease = PressRelease::factory()->create();
|
||||
$image = PressReleaseImage::factory()->for($pressRelease)->rulesV2()->create();
|
||||
$confirmation = makeConfirmationFor($image);
|
||||
|
||||
$pressRelease->forceDelete();
|
||||
|
||||
expect($confirmation->fresh())->not->toBeNull()
|
||||
->and($confirmation->fresh()?->press_release_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('the ip derivation is keyed, versioned and not a plain hash', function () {
|
||||
config()->set('images.confirmation_ip_key', 'geheim-1');
|
||||
$first = ImageRightsConfirmation::hashIp('203.0.113.7');
|
||||
|
||||
config()->set('images.confirmation_ip_key', 'geheim-2');
|
||||
$second = ImageRightsConfirmation::hashIp('203.0.113.7');
|
||||
|
||||
expect($first)->toStartWith('v1:')
|
||||
->and($first)->not->toBe($second)
|
||||
->and($first)->not->toContain(hash('sha256', '203.0.113.7'))
|
||||
->and(ImageRightsConfirmation::hashIp(null))->toBeNull();
|
||||
});
|
||||
|
||||
test('the image exposes its latest valid confirmation bound to the current file hash', function () {
|
||||
$image = PressReleaseImage::factory()->rulesV2()->create();
|
||||
|
||||
expect($image->hasValidRightsConfirmation())->toBeFalse()
|
||||
->and($image->isLegacyRules())->toBeFalse();
|
||||
|
||||
makeConfirmationFor($image);
|
||||
|
||||
expect($image->fresh()->hasValidRightsConfirmation())->toBeTrue();
|
||||
|
||||
// Bildtausch: neuer Hash, alte Bestätigung zählt nicht mehr (F09).
|
||||
$image->update(['original_sha256' => hash('sha256', 'neue-datei')]);
|
||||
|
||||
expect($image->fresh()->hasValidRightsConfirmation())->toBeFalse()
|
||||
->and(PressReleaseImage::factory()->legacy()->create()->isLegacyRules())->toBeTrue();
|
||||
});
|
||||
257
tests/Feature/Images/PressReleaseImageUploaderTest.php
Normal file
257
tests/Feature/Images/PressReleaseImageUploaderTest.php
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
<?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([]);
|
||||
});
|
||||
93
tests/Feature/Legal/LegalTextTest.php
Normal file
93
tests/Feature/Legal/LegalTextTest.php
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
<?php
|
||||
|
||||
use App\Models\ImageRightsConfirmation;
|
||||
use App\Models\LegalDocument;
|
||||
use App\Models\LegalText;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseImage;
|
||||
use App\Services\Image\ImageRightsTextRenderer;
|
||||
use Database\Seeders\LegalDocumentSeeder;
|
||||
use Database\Seeders\LegalTextSeeder;
|
||||
use Tests\TestCase;
|
||||
|
||||
test('the seeder registers the three confirmation texts idempotently and never rewrites one', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(LegalTextSeeder::class);
|
||||
$this->seed(LegalTextSeeder::class);
|
||||
|
||||
expect(LegalText::query()->count())->toBe(3);
|
||||
|
||||
$v2 = LegalText::findByKey('img-confirm-v2');
|
||||
expect($v2?->body_sha256)->toBe(hash('sha256', (string) $v2?->body))
|
||||
->and($v2?->bound_agb_version)->toBe('0.6')
|
||||
->and(LegalText::findByKey('img-confirm-v3')?->effective_from)->toBeNull();
|
||||
});
|
||||
|
||||
test('currentFor picks the text bound to the active agb version and ignores fassungen without a date', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(LegalTextSeeder::class);
|
||||
|
||||
expect(LegalText::currentFor(LegalText::TYPE_IMAGE_RIGHTS_CONFIRMATION, '0.6')?->key)->toBe('img-confirm-v2')
|
||||
->and(LegalText::currentFor(LegalText::TYPE_IMAGE_RIGHTS_CONFIRMATION, '0.7'))->toBeNull()
|
||||
->and(LegalText::findByKey('img-confirm-v2')?->isCurrentFor('0.6'))->toBeTrue()
|
||||
->and(LegalText::findByKey('img-confirm-v1')?->isCurrentFor('0.6'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('legal texts are immutable', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(LegalTextSeeder::class);
|
||||
$text = LegalText::findByKey('img-confirm-v2');
|
||||
|
||||
expect(fn () => $text->update(['body' => 'geändert']))->toThrow(LogicException::class)
|
||||
->and(fn () => $text->delete())->toThrow(LogicException::class);
|
||||
});
|
||||
|
||||
test('the renderer resolves agb version, date and url into the long and short wording', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(LegalDocumentSeeder::class);
|
||||
$this->seed(LegalTextSeeder::class);
|
||||
|
||||
$renderer = app(ImageRightsTextRenderer::class);
|
||||
$text = $renderer->current();
|
||||
|
||||
expect($text?->key)->toBe('img-confirm-v2');
|
||||
|
||||
$long = $renderer->renderLong($text, 'https://presseecho.test/de/agb');
|
||||
$short = $renderer->renderShort($text, 'https://presseecho.test/de/agb');
|
||||
|
||||
expect($long)->toContain('Fassung vom 3. Juli 2026 (Version 0.6, https://presseecho.test/de/agb)')
|
||||
->toContain('presseecho.de und businessportal24.com')
|
||||
->toContain('§ 8 und § 9')
|
||||
->not->toContain('{agb_version}')
|
||||
->and($short)->toContain('Fassung vom 3. Juli 2026')
|
||||
->and(LegalDocument::currentAgbVersion())->toBe('0.6');
|
||||
});
|
||||
|
||||
test('the backfill command gives every old confirmation a protocol row pointing at img-confirm-v1', function () {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(LegalTextSeeder::class);
|
||||
|
||||
$pressRelease = PressRelease::factory()->create();
|
||||
$confirmed = PressReleaseImage::factory()->for($pressRelease)->create(['rights_confirmed_at' => now()->subMonth()]);
|
||||
PressReleaseImage::factory()->for($pressRelease)->legacy()->create();
|
||||
|
||||
$this->artisan('image-rights:backfill-confirmations', ['--dry-run' => true])
|
||||
->expectsOutputToContain('Bilder mit alter Bestätigung ohne Protokoll: 1')
|
||||
->assertSuccessful();
|
||||
|
||||
expect(ImageRightsConfirmation::query()->count())->toBe(0);
|
||||
|
||||
$this->artisan('image-rights:backfill-confirmations')->assertSuccessful();
|
||||
$this->artisan('image-rights:backfill-confirmations')->assertSuccessful();
|
||||
|
||||
$confirmation = $confirmed->rightsConfirmations()->sole();
|
||||
|
||||
expect(ImageRightsConfirmation::query()->count())->toBe(1)
|
||||
->and($confirmation->legal_text_key)->toBe('img-confirm-v1')
|
||||
->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->confirmed_at->toDateTimeString())->toBe($confirmed->rights_confirmed_at->toDateTimeString())
|
||||
->and($confirmation->declaration_snapshot['backfilled'])->toBeTrue()
|
||||
->and($confirmation->text_snapshot)->toBe(LegalText::findByKey('img-confirm-v1')?->body);
|
||||
});
|
||||
4
tests/Fixtures/images/broken.jpg
Normal file
4
tests/Fixtures/images/broken.jpg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
ЪьЪЮПiц│?°·Р┌╪~
|
||||
й█ФЪвфB÷Н╡┘T┐шЮ╞ф²В╖│r┤@╥╕Е)Jb2В≥╦9щ\╔X∙│╤4Ч░фX█нАAр╠Г╬QЩ#ВS⌠>9Ъб█bАся3<LвВ·лэOл╟о0╬ДП}eqхсЛрgцц}┤
|
||||
ё{У╣Jьyф°уM╥*а≈{Г┌>мй▓║ЦЖБm@р╝'T@2Е!IBцb%┤ы]т@┼Щ]┴CE^r ├;
|
||||
ж÷°█ФЩп
|
||||
|
After Width: | Height: | Size: 204 B |
BIN
tests/Fixtures/images/camera-iptc.jpg
Normal file
BIN
tests/Fixtures/images/camera-iptc.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 437 B |
85
tests/Fixtures/images/generate-fixtures.php
Normal file
85
tests/Fixtures/images/generate-fixtures.php
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Erzeugt die Fixture-Dateien für die Herkunftsprüfung (Bildkennzeichnung
|
||||
* A.5) einmalig mit Imagick. Aufruf: php tests/Fixtures/images/generate-fixtures.php
|
||||
*
|
||||
* Die Dateien werden eingecheckt; das Skript dient nur der Nachvollziehbarkeit.
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
$dir = __DIR__;
|
||||
|
||||
function iptcBlock(array $datasets): string
|
||||
{
|
||||
// IPTC-IIM: 0x1C, Record 2, Dataset, Länge (16 Bit), Daten.
|
||||
$block = '';
|
||||
foreach ($datasets as $dataset => $values) {
|
||||
foreach ((array) $values as $value) {
|
||||
$block .= "\x1C\x02".chr($dataset).pack('n', strlen($value)).$value;
|
||||
}
|
||||
}
|
||||
|
||||
return $block;
|
||||
}
|
||||
|
||||
function xmp(array $description): string
|
||||
{
|
||||
return '<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>'
|
||||
.'<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">'
|
||||
.'<rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/" xmlns:Iptc4xmpExt="http://iptc.org/std/Iptc4xmpExt/2008-02-29/">'
|
||||
.implode('', $description)
|
||||
.'</rdf:Description></rdf:RDF></x:xmpmeta><?xpacket end="w"?>';
|
||||
}
|
||||
|
||||
function make(string $file, string $format, ?string $iptc = null, ?string $xmp = null): void
|
||||
{
|
||||
$image = new Imagick;
|
||||
$image->newImage(64, 48, new ImagickPixel('#4a6fa5'), $format);
|
||||
$image->setImageFormat($format);
|
||||
|
||||
if ($iptc !== null) {
|
||||
$image->setImageProfile('iptc', $iptc);
|
||||
}
|
||||
|
||||
if ($xmp !== null) {
|
||||
$image->setImageProfile('xmp', $xmp);
|
||||
}
|
||||
|
||||
$image->writeImage($file);
|
||||
$image->clear();
|
||||
echo "written: {$file}\n";
|
||||
}
|
||||
|
||||
// 1. Kamera-JPEG mit klassischem IPTC (Credit, By-line, Copyright).
|
||||
make("{$dir}/camera-iptc.jpg", 'jpeg', iptcBlock([
|
||||
110 => 'Musterfirma GmbH / Anna Beispiel',
|
||||
80 => 'Anna Beispiel',
|
||||
116 => '© 2026 Musterfirma GmbH',
|
||||
]));
|
||||
|
||||
// 2. JPEG mit XMP: KI-generiert (volle IPTC-URI) und Credit als Element.
|
||||
make("{$dir}/xmp-ai-generated.jpg", 'jpeg', null, xmp([
|
||||
'<Iptc4xmpExt:DigitalSourceType>http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia</Iptc4xmpExt:DigitalSourceType>',
|
||||
'<photoshop:Credit>KI-Bild / Musterfirma</photoshop:Credit>',
|
||||
]));
|
||||
|
||||
// 3. JPEG mit XMP algorithmicallyEnhanced (Kurzform) und dc:creator-Liste.
|
||||
make("{$dir}/xmp-enhanced.jpg", 'jpeg', null, xmp([
|
||||
'<Iptc4xmpExt:DigitalSourceType>algorithmicallyEnhanced</Iptc4xmpExt:DigitalSourceType>',
|
||||
'<dc:creator><rdf:Seq><rdf:li>Max Mustermann</rdf:li></rdf:Seq></dc:creator>',
|
||||
]));
|
||||
|
||||
// 4. PNG ohne Metadaten.
|
||||
make("{$dir}/plain.png", 'png');
|
||||
|
||||
// 5. WebP mit XMP als Attribute (digitalCapture, dc:rights).
|
||||
make("{$dir}/webp-capture.webp", 'webp', null, xmp([
|
||||
'<Iptc4xmpExt:DigitalSourceType>digitalCapture</Iptc4xmpExt:DigitalSourceType>',
|
||||
'<dc:rights><rdf:Alt><rdf:li xml:lang="x-default">© Beispiel AG</rdf:li></rdf:Alt></dc:rights>',
|
||||
]));
|
||||
|
||||
// 6. Beschädigte Datei: JPEG-Header, dann Müll.
|
||||
file_put_contents("{$dir}/broken.jpg", "\xFF\xD8\xFF\xE0".random_bytes(200));
|
||||
echo "written: {$dir}/broken.jpg\n";
|
||||
BIN
tests/Fixtures/images/plain.png
Normal file
BIN
tests/Fixtures/images/plain.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 146 B |
BIN
tests/Fixtures/images/webp-capture.webp
Normal file
BIN
tests/Fixtures/images/webp-capture.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 678 B |
BIN
tests/Fixtures/images/xmp-ai-generated.jpg
Normal file
BIN
tests/Fixtures/images/xmp-ai-generated.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 948 B |
BIN
tests/Fixtures/images/xmp-enhanced.jpg
Normal file
BIN
tests/Fixtures/images/xmp-enhanced.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 918 B |
Loading…
Add table
Add a link
Reference in a new issue