newsroom: Verwaltungszugang „Diesen Newsroom verwalten" (Roadmap 1b.2)

Neue Seite /newsroom/{slug}/verwalten auf den Web-Portalen: schickt den
Anmeldelink an die hinterlegte Firmen-E-Mail (sonst Pressekontakte), zeigt
sie nur maskiert, und funktioniert auch für Firmen ohne veröffentlichte
Meldung. Konto, Zuordnung und Login laufen durch ContactAccessService —
neu dort: Herkunft (profile_claim auf dem Token) und Ziel-Firma, auf deren
Kundenseite der Login landet. 410-entfernte bleiben Tombstone, aufgegangene
Slugs führen zum Überlebenden (MergedNewsroomLookup, geteilt mit der
Newsroom-Seite). 3 Links je Newsroom und Tag, 15 je IP und Stunde.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-15 10:42:20 +00:00
parent abfcd774e0
commit 9143cc4a54
15 changed files with 665 additions and 37 deletions

View file

@ -0,0 +1,154 @@
<?php
use App\Enums\ConversionEventType;
use App\Enums\ConversionSource;
use App\Enums\Portal;
use App\Mail\MagicLoginLink;
use App\Models\Company;
use App\Models\CompanyEmailGroup;
use App\Models\Contact;
use App\Models\ConversionEvent;
use App\Models\MagicLink;
use App\Models\User;
use App\Services\Company\CompanyMergeService;
use App\Services\Company\CompanyRemovalService;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
use Tests\TestCase;
/**
* „Diesen Newsroom verwalten" (Roadmap 1b.2): Verwaltungszugang über die
* hinterlegte Firmen-E-Mail, unabhängig von der öffentlichen Sichtbarkeit.
*/
beforeEach(function (): void {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
Mail::fake();
RateLimiter::clear('newsroom-claim|ip|127.0.0.1');
});
function claimCompany(array $attributes = []): Company
{
return Company::factory()->create(array_merge([
'portal' => Portal::Businessportal24->value,
'name' => 'Muster GmbH',
'slug' => 'muster-gmbh',
'email' => 'info@muster.de',
'is_active' => true,
], $attributes));
}
function claimUrl(string $slug = 'muster-gmbh'): string
{
return 'https://businessportal24.test/de/newsroom/'.$slug.'/verwalten';
}
test('the claim page is reachable without a single published release and shows only a masked address', function () {
/** @var TestCase $this */
claimCompany();
// Die öffentliche Newsroom-Seite gibt es noch nicht — den Zugang schon.
$this->get('https://businessportal24.test/de/newsroom/muster-gmbh')->assertNotFound();
$this->get(claimUrl())
->assertOk()
->assertSeeText('Muster GmbH')
->assertSeeText('i***@muster.de')
->assertDontSeeText('info@muster.de');
});
test('requesting the link creates the account, sends the login link with profile_claim on the token and lands on the company page', function () {
/** @var TestCase $this */
$company = claimCompany();
$this->post(claimUrl())->assertRedirect()->assertSessionHas('newsroom-claim-status');
$user = User::query()->where('email', 'info@muster.de')->firstOrFail();
expect($user->accessibleCompanyIds())->toContain($company->id);
Mail::assertQueued(MagicLoginLink::class, fn (MagicLoginLink $mail) => $mail->user->is($user));
$link = MagicLink::query()->where('user_id', $user->id)->where('purpose', 'login')->latest('id')->firstOrFail();
expect($link->source)->toBe(ConversionSource::ProfileClaim->value)
->and($link->payload['company_id'])->toBe($company->id);
// Token aus der Mail nachstellen: Der Login landet auf der Firma, nicht
// auf dem Dashboard — und Stufe 2 der Leiter trägt die Herkunft.
$plain = str_replace('/magic-login/', '', parse_url(Mail::queued(MagicLoginLink::class)->first()->loginUrl, PHP_URL_PATH));
$this->get('https://pressekonto.test/magic-login/'.ltrim($plain, '/'))
->assertRedirect(route('me.press-kits.show', ['id' => $company->id], absolute: false));
expect(ConversionEvent::query()->where('type', ConversionEventType::MagicLinkConsumed->value)->first()->source)
->toBe(ConversionSource::ProfileClaim);
});
test('without a company email the link goes to the press contacts', function () {
/** @var TestCase $this */
$company = claimCompany(['email' => null]);
Contact::factory()->for($company)->create(['email' => 'Paula@muster.de', 'portal' => 'businessportal24']);
Contact::factory()->for($company)->create(['email' => 'peter@muster.de', 'portal' => 'businessportal24']);
$this->get(claimUrl())->assertSeeText('p***@muster.de');
$this->post(claimUrl())->assertSessionHas('newsroom-claim-status');
Mail::assertQueuedCount(2);
});
test('without any address the page explains it and sends nothing', function () {
/** @var TestCase $this */
claimCompany(['email' => null]);
$this->get(claimUrl())->assertOk()->assertSeeText('keine E-Mail-Adresse hinterlegt');
$this->post(claimUrl())->assertSessionHas('newsroom-claim-none');
Mail::assertNothingQueued();
});
test('links per newsroom are limited per day', function () {
/** @var TestCase $this */
$company = claimCompany();
RateLimiter::clear('newsroom-claim|company|'.$company->id);
config()->set('newsroom.claim.links_per_day', 2);
$this->post(claimUrl())->assertSessionHas('newsroom-claim-status');
$this->post(claimUrl())->assertSessionHas('newsroom-claim-status');
$this->post(claimUrl())->assertSessionHasErrors('claim');
Mail::assertQueuedCount(2);
});
test('a removed newsroom answers 410 and a merged-away slug redirects to the survivor', function () {
/** @var TestCase $this */
$gone = claimCompany(['slug' => 'weg-gmbh', 'email' => 'weg@muster.de']);
app(CompanyRemovalService::class)->markGone($gone);
$this->get(claimUrl('weg-gmbh'))->assertStatus(410);
$this->post(claimUrl('weg-gmbh'))->assertStatus(410);
// Zusammenführung: alte Hälfte auf PE mit anderem Slug geht im
// B24-Überlebenden auf → der Zugang folgt dem 301 der Newsroom-Seite.
claimCompany();
claimCompany(['portal' => Portal::Presseecho->value, 'slug' => 'muster']);
CompanyEmailGroup::query()->create([
'email' => 'info@muster.de', 'classification' => 'dublette', 'companies_count' => 2, 'distinct_names' => 1,
'published_companies_count' => 0, 'threshold' => 3, 'classified_at' => now(),
]);
$service = app(CompanyMergeService::class);
$service->merge($service->plan(CompanyEmailGroup::forEmail('info@muster.de')), 'b');
$this->get('https://presseecho.test/de/newsroom/muster/verwalten')
->assertRedirect('https://presseecho.test/de/newsroom/muster-gmbh/verwalten')
->assertStatus(301);
$this->get('https://businessportal24.test/de/newsroom/unbekannt/verwalten')->assertNotFound();
});
test('the honeypot answers neutrally without sending', function () {
/** @var TestCase $this */
claimCompany();
$this->post(claimUrl(), ['website' => 'http://spam'])->assertSessionHas('newsroom-claim-status');
Mail::assertNothingQueued();
});