seed(RolesAndPermissionsSeeder::class); $this->seed(LegalDocumentSeeder::class); $this->seed(LegalTextSeeder::class); Storage::fake('public'); Storage::fake('local'); config()->set('images.rights_v2', true); }); /** * @return array{owner: User, pr: PressRelease, company: Company} */ function makeRightsV2Draft(): array { $owner = User::factory()->create(['is_active' => true]); $owner->assignRole('customer'); $company = Company::factory()->presseecho()->create(['name' => 'Beispiel AG']); $owner->companies()->attach($company->id, ['role' => 'owner']); $pr = PressRelease::factory()->create([ 'user_id' => $owner->id, 'company_id' => $company->id, 'category_id' => Category::factory()->create()->id, 'portal' => $company->portal->value, 'status' => 'draft', ]); return compact('owner', 'pr', 'company'); } function rightsV2Fixture(string $name): UploadedFile { return UploadedFile::fake()->createWithContent($name, (string) file_get_contents(base_path('tests/Fixtures/images/'.$name))); } /** * Veröffentlichte PM mit öffentlicher Detailseite auf presseecho. * * @return array{owner: User, pr: PressRelease, company: Company} */ function makeRightsV2Published(): array { $owner = User::factory()->create(['is_active' => true]); $owner->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]); $owner->companies()->attach($company->id, ['role' => 'owner']); $pr = PressRelease::factory()->published()->forPortal(Portal::Presseecho)->for($category)->for($company)->create([ 'user_id' => $owner->id, 'slug' => 'rights-v2-'.fake()->unique()->numberBetween(1, 99999), 'published_at' => now()->subDay(), 'published_ever_at' => now()->subDay(), ]); return compact('owner', 'pr', 'company'); } /** * Simuliert Deploy 3: neue AGB-Fassung ab heute samt daran gebundenem * Bestätigungstext. LegalText::currentFor wählt den an die aktuelle * AGB-Fassung gebundenen Text, bisher gilt img-confirm-v2 (AGB 0.6). */ function activateRightsTextV4(): void { LegalDocument::factory()->agb()->create(['version' => '0.9-test', 'effective_from' => today()]); $body = 'Neue Fassung der Bestätigung der Bildrechte (AGB {agb_version}).'; LegalText::query()->create([ 'key' => 'img-confirm-v4', 'type' => LegalText::TYPE_IMAGE_RIGHTS_CONFIRMATION, 'locale' => 'de', 'title' => 'Bestätigung der Bildrechte', 'body' => $body, 'short_body' => 'Ich bestätige die Rechte am Bild (AGB {agb_version}).', 'bound_agb_version' => '0.9-test', 'body_sha256' => hash('sha256', $body), 'effective_from' => today(), 'note' => 'Testfassung', ]); } test('the edit page embeds the v2 manager only while the flag is on', function () { /** @var TestCase $this */ ['owner' => $owner, 'pr' => $pr] = makeRightsV2Draft(); $this->actingAs($owner); LivewireVolt::test('customer.press-releases.edit', ['id' => $pr->id]) ->assertSeeLivewire('components.press-release-images-manager-v2'); config()->set('images.rights_v2', false); LivewireVolt::test('customer.press-releases.edit', ['id' => $pr->id]) ->assertSeeLivewire('components.press-release-images-manager') ->assertDontSeeLivewire('components.press-release-images-manager-v2'); }); test('nachweis, kennzeichnung and the confirmation are the only required inputs', function () { /** @var TestCase $this */ ['owner' => $owner, 'pr' => $pr] = makeRightsV2Draft(); $this->actingAs($owner); LivewireVolt::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id]) ->call('openUploadForm') ->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800)) ->set('newCopyright', '') ->call('saveImage') ->assertHasErrors(['newCopyright', 'newAiDeclaration', 'newRightsConfirmed']) ->assertHasNoErrors(['newTitle', 'newLicenseUrl', 'newSourceUrl']); expect($pr->images()->count())->toBe(0); }); test('without metadata the company name is suggested as credit and the state says so', function () { /** @var TestCase $this */ ['owner' => $owner, 'pr' => $pr] = makeRightsV2Draft(); $this->actingAs($owner); LivewireVolt::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id]) ->call('openUploadForm') ->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800)) ->assertSet('newCopyright', 'Beispiel AG') ->assertSet('provenanceStatus', 'absent') ->assertSee('Keine Herkunftsdaten in der Datei gefunden.'); }); test('iptc and xmp metadata prefill credit and ai declaration and can be changed', function () { /** @var TestCase $this */ ['owner' => $owner, 'pr' => $pr] = makeRightsV2Draft(); $this->actingAs($owner); $component = LivewireVolt::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id]) ->call('openUploadForm') ->set('newImage', rightsV2Fixture('camera-iptc.jpg')) ->assertSet('newCopyright', 'Musterfirma GmbH / Anna Beispiel') ->assertSet('newAiDeclaration', '') ->assertSee('Angaben aus den Metadaten der Datei vorbelegt.'); $component ->set('newImage', rightsV2Fixture('xmp-ai-generated.jpg')) ->assertSet('newCopyright', 'KI-Bild / Musterfirma') ->assertSet('newAiDeclaration', 'ai_generated') ->assertSee('Sie können die Auswahl ändern') ->set('newAiDeclaration', 'original'); expect($component->get('newAiDeclaration'))->toBe('original'); }); test('a manual credit survives a file change but the confirmation toggle does not', function () { /** @var TestCase $this */ ['owner' => $owner, 'pr' => $pr] = makeRightsV2Draft(); $this->actingAs($owner); LivewireVolt::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id]) ->call('openUploadForm') ->set('newImage', UploadedFile::fake()->image('a.jpg', 640, 480)) ->set('newCopyright', 'Eigener Nachweis') ->set('newRightsConfirmed', true) ->set('newImage', rightsV2Fixture('camera-iptc.jpg')) ->assertSet('newCopyright', 'Eigener Nachweis') ->assertSet('newRightsConfirmed', false); }); test('an unreadable file shows the error state and is not accepted', function () { /** @var TestCase $this */ ['owner' => $owner, 'pr' => $pr] = makeRightsV2Draft(); $this->actingAs($owner); LivewireVolt::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id]) ->call('openUploadForm') ->set('newImage', rightsV2Fixture('broken.jpg')) ->assertHasErrors(['newImage']); }); test('a valid upload stores the image under the new rules with a confirmation and shows it on the card', function () { /** @var TestCase $this */ ['owner' => $owner, 'pr' => $pr] = makeRightsV2Draft(); $this->actingAs($owner); LivewireVolt::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id]) ->call('openUploadForm') ->set('newImage', rightsV2Fixture('camera-iptc.jpg')) ->set('newAiDeclaration', AiImageDeclaration::Original->value) ->set('newTitle', 'Werkshalle') ->set('newLicenseUrl', 'https://example.com/lizenz') ->set('focus', '100,0') ->set('newRightsConfirmed', true) ->call('saveImage') ->assertHasNoErrors() ->assertSet('isUploadFormOpen', false) ->assertDispatched('title-image-changed') ->assertSee('Rechte bestätigt am') ->assertSee('img-confirm-v2'); $image = $pr->images()->sole(); expect($image->rules_version)->toBe(config('images.rules_version')) ->and($image->copyright)->toBe('Musterfirma GmbH / Anna Beispiel') ->and($image->credit_source?->value)->toBe('metadata') ->and($image->title)->toBe('Werkshalle') ->and($image->license_url)->toBe('https://example.com/lizenz') ->and($image->focus_x)->toBe(100) ->and($image->focus_y)->toBe(0) ->and($image->license_type)->toBeNull() ->and($image->people_rights_status)->toBeNull() ->and(Storage::disk('local')->exists($image->original_path))->toBeTrue() ->and($image->hasValidRightsConfirmation())->toBeTrue(); $confirmation = ImageRightsConfirmation::query()->sole(); expect($confirmation->channel)->toBe(ConfirmationChannel::Form) ->and($confirmation->user_id)->toBe($owner->id) ->and($confirmation->legal_text_key)->toBe('img-confirm-v2') ->and($confirmation->text_snapshot)->toContain('/de/agb'); }); test('an admin upload skips the confirmation, mails the customer and blocks nothing else', function () { /** @var TestCase $this */ Mail::fake(); ['pr' => $pr, 'owner' => $owner] = makeRightsV2Draft(); $admin = User::factory()->create(['is_active' => true]); $admin->assignRole('admin'); $this->actingAs($admin); LivewireVolt::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id]) ->call('openUploadForm') ->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800)) ->set('newCopyright', 'Beispiel AG') ->set('newAiDeclaration', AiImageDeclaration::Original->value) ->assertSee('Sie laden im Auftrag des Kunden hoch') ->call('saveImage') ->assertHasNoErrors() ->assertSee('wartet auf Kundenbestätigung'); $image = $pr->images()->sole(); expect($image->customer_confirmation_requested_at)->not->toBeNull() ->and($image->awaitsCustomerConfirmation())->toBeTrue() ->and(ImageRightsConfirmation::query()->count())->toBe(0); Mail::assertQueued(PressReleaseImageConfirmationRequested::class, fn ($mail) => $mail->hasTo($owner->email) && $mail->image->is($image)); }); test('the customer confirms an admin upload on the detail page with the magic link channel', function () { /** @var TestCase $this */ ['pr' => $pr, 'owner' => $owner] = makeRightsV2Draft(); $image = PressReleaseImage::factory()->for($pr)->rulesV2()->create([ 'rights_confirmed_at' => null, 'customer_confirmation_requested_at' => now(), ]); $this->actingAs($owner); LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id]) ->assertSee('Titelbild bestätigen') ->call('confirmImageRights') ->assertHasErrors(['imageRightsConfirmed']) ->set('imageRightsConfirmed', true) ->call('confirmImageRights') ->assertHasNoErrors() ->assertDontSee('Titelbild bestätigen'); $confirmation = $image->rightsConfirmations()->sole(); expect($confirmation->channel)->toBe(ConfirmationChannel::MagicLink) ->and($confirmation->user_id)->toBe($owner->id) ->and($confirmation->file_sha256)->toBe($image->original_sha256) ->and($image->fresh()->awaitsCustomerConfirmation())->toBeFalse() ->and($image->fresh()->rights_confirmed_at)->not->toBeNull(); }); test('removing a never published image deletes original, derivatives and confirmation for good', function () { /** @var TestCase $this */ ['owner' => $owner, 'pr' => $pr] = makeRightsV2Draft(); $this->actingAs($owner); $component = LivewireVolt::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id]) ->call('openUploadForm') ->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800)) ->set('newAiDeclaration', AiImageDeclaration::Original->value) ->set('newRightsConfirmed', true) ->call('saveImage') ->assertHasNoErrors(); $image = $pr->images()->sole(); expect(Storage::disk('local')->allFiles())->not->toBe([]); $component->call('remove', $image->id)->assertDispatched('title-image-changed'); expect(PressReleaseImage::withTrashed()->whereKey($image->id)->exists())->toBeFalse() ->and(ImageRightsConfirmation::query()->count())->toBe(0) ->and(Storage::disk('local')->allFiles())->toBe([]); }); test('removing a once published image moves original and confirmation into the evidence archive and only soft deletes', function () { /** @var TestCase $this */ Storage::fake('archive'); config()->set('archive.key', 'base64:'.base64_encode(random_bytes(32))); ['owner' => $owner, 'pr' => $pr] = makeRightsV2Draft(); $admin = User::factory()->create(['is_active' => true]); $admin->assignRole('admin'); $this->actingAs($admin); LivewireVolt::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id]) ->call('openUploadForm') ->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800)) ->set('newCopyright', 'Beispiel AG') ->set('newAiDeclaration', AiImageDeclaration::Original->value) ->call('saveImage') ->assertHasNoErrors(); $image = $pr->images()->sole(); $image->forceFill(['published_ever_at' => now()])->save(); LivewireVolt::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id]) ->call('remove', $image->id); $trashed = PressReleaseImage::withTrashed()->whereKey($image->id)->sole(); // Neue Semantik (Umsetzungsaudit 25.09.2026): Original und Bestätigung // liegen im Beweisarchiv, die lokale Beweisdatei ist weg, der Pfad an // der Zeile geleert (Bestätigungszeilen bleiben bis zum Fristende — // siehe EvidenceArchiveTest). expect($trashed->trashed())->toBeTrue() ->and(ArchiveEntry::query()->where('event', 'image_removed')->where('subject_id', $image->id)->exists())->toBeTrue() ->and(Storage::disk('local')->exists($image->original_path))->toBeFalse() ->and($trashed->original_path)->toBeNull() ->and(Storage::disk('local')->exists($image->path))->toBeFalse(); }); test('an admin upload on a published press release stays hidden on page, og and media route until the owner confirms it', function () { /** @var TestCase $this */ Mail::fake(); ['pr' => $pr, 'owner' => $owner] = makeRightsV2Published(); $admin = User::factory()->create(['is_active' => true]); $admin->assignRole('admin'); $this->actingAs($admin); LivewireVolt::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id]) ->call('openUploadForm') ->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800)) ->set('newCopyright', 'Beispiel AG') ->set('newAiDeclaration', AiImageDeclaration::Original->value) ->call('saveImage') ->assertHasNoErrors(); $image = $pr->images()->sole(); $mediaUrl = route('media.press-release-images.show', ['image' => $image->id, 'variant' => 'cover']); $pageUrl = 'https://presseecho.test/de/press-release/'.$pr->slug; expect($image->isPubliclyDeliverable())->toBeFalse() ->and(PressReleaseImage::query()->publiclyVisible()->whereKey($image->id)->exists())->toBeFalse(); // Berechtigte interne Ansicht bleibt, aber ohne öffentlichen Cache. $this->get($mediaUrl)->assertSuccessful()->assertHeader('Cache-Control', 'no-store, private'); auth()->logout(); $this->get($pageUrl)->assertSuccessful()->assertDontSee('media/press-release-images/'.$image->id, false); $this->get($mediaUrl)->assertNotFound(); $this->actingAs($owner); LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id]) ->assertSee('Titelbild bestätigen') ->set('imageRightsConfirmed', true) ->call('confirmImageRights') ->assertHasNoErrors(); $image = $image->fresh(); expect($image->rights_confirmed_at)->not->toBeNull() ->and($image->isPubliclyDeliverable())->toBeTrue() ->and($image->published_snapshot_path)->not->toBeNull() ->and($image->rightsConfirmations()->sole()->channel)->toBe(ConfirmationChannel::MagicLink); auth()->logout(); $this->get($pageUrl) ->assertSuccessful() ->assertSee('', false); $this->get($mediaUrl)->assertSuccessful()->assertHeader('Cache-Control', 'max-age=86400, public'); }); test('only the account owner may confirm an admin upload: team member, super admin and a repeat are refused', function () { /** @var TestCase $this */ ['pr' => $pr, 'owner' => $owner, 'company' => $company] = makeRightsV2Draft(); $image = PressReleaseImage::factory()->for($pr)->rulesV2()->create([ 'rights_confirmed_at' => null, 'customer_confirmation_requested_at' => now(), ]); $member = User::factory()->create(['is_active' => true]); $member->assignRole('customer'); $member->companies()->attach($company->id, ['role' => 'member']); $superAdmin = User::factory()->superAdmin()->create(['is_active' => true]); expect($member->can('update', $pr))->toBeTrue() ->and($member->can('confirmImageRights', $pr))->toBeFalse() ->and($superAdmin->can('update', $pr))->toBeTrue() ->and($superAdmin->can('confirmImageRights', $pr))->toBeFalse() ->and($owner->can('confirmImageRights', $pr))->toBeTrue(); $this->actingAs($member); LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id]) ->assertDontSee('Titelbild bestätigen') ->set('imageRightsConfirmed', true) ->call('confirmImageRights') ->assertForbidden(); $confirm = app(CustomerImageConfirmation::class); // Der Service prüft die Identität unabhängig von der Policy. expect(fn () => $confirm->confirm($image, $member, 'img-confirm-v2', null, null)) ->toThrow(ImageUploadException::class, 'Kontoinhaber'); expect(ImageRightsConfirmation::query()->count())->toBe(0); $confirm->confirm($image->fresh(), $owner, 'img-confirm-v2', null, null); expect(fn () => $confirm->confirm($image->fresh(), $owner, 'img-confirm-v2', null, null)) ->toThrow(ImageUploadException::class, 'keine Bestätigung aus'); expect(ImageRightsConfirmation::query()->count())->toBe(1); }); test('a confirmation text replaced between render and save must be read and confirmed again in the manager', function () { /** @var TestCase $this */ ['pr' => $pr, 'owner' => $owner] = makeRightsV2Draft(); $this->actingAs($owner); $component = LivewireVolt::test('components.press-release-images-manager-v2', ['pressReleaseId' => $pr->id]) ->assertSet('legalTextKey', 'img-confirm-v2') ->call('openUploadForm') ->set('newImage', UploadedFile::fake()->image('foto.jpg', 1200, 800)) ->set('newCopyright', 'Beispiel AG') ->set('newAiDeclaration', AiImageDeclaration::Original->value) ->set('newRightsConfirmed', true); activateRightsTextV4(); $component->call('saveImage') ->assertHasErrors(['newRightsConfirmed']) ->assertSet('newRightsConfirmed', false) ->assertSet('legalTextKey', 'img-confirm-v4'); expect($pr->images()->count())->toBe(0); $component->set('newRightsConfirmed', true) ->call('saveImage') ->assertHasNoErrors(); expect($pr->images()->sole()->rightsConfirmations()->sole()->legal_text_key)->toBe('img-confirm-v4'); }); test('the confirmation on the detail page is bound to the text version shown', function () { /** @var TestCase $this */ ['pr' => $pr, 'owner' => $owner] = makeRightsV2Draft(); $image = PressReleaseImage::factory()->for($pr)->rulesV2()->create([ 'rights_confirmed_at' => null, 'customer_confirmation_requested_at' => now(), ]); $this->actingAs($owner); $component = LivewireVolt::test('customer.press-releases.show', ['id' => $pr->id]) ->assertSet('imageLegalTextKey', 'img-confirm-v2') ->set('imageRightsConfirmed', true); activateRightsTextV4(); $component->call('confirmImageRights') ->assertHasErrors(['imageRightsConfirmed']) ->assertSet('imageRightsConfirmed', false) ->assertSet('imageLegalTextKey', 'img-confirm-v4'); expect(ImageRightsConfirmation::query()->count())->toBe(0); $component->set('imageRightsConfirmed', true) ->call('confirmImageRights') ->assertHasNoErrors(); expect($image->rightsConfirmations()->sole()->legal_text_key)->toBe('img-confirm-v4'); });