presseportale/tests/Feature/Api/ApiNewsroomAssignmentTest.php
Kevin Adametz 1ccb4a8479 auth/security: API-Firmenzuordnung nur für Mitglieder oder Allowlist, Google-Vorabübernahme geschlossen, gemeinsamer Login-Abschluss (Umsetzungsaudit 29.09.: R-01, K-04, K-07, K-08, B-10)
- R-01: ApiNewsroomAssignment hängt an eine bestehende Firma nur noch Owner,
  Mitglieder oder Nutzer aus NEWSROOM_API_ASSIGNMENT_USER_IDS; Pivot mit
  granted_via = api; Befehl company-memberships:api-assignments (Liste,
  --remove mit Vorschau)
- K-04: OAuth-Verifikation eines offenen Kontos verwirft Passwort,
  remember_token und Sanctum-Tokens; SessionRevocation + Middleware
  EnsureSessionIsNotRevoked widerruft alte Sessions (Redis-tauglich)
- K-08: LoginCompletion für Passwort, Magic Link und Google — last_login_at
  und Anmeldemethode auch nach 2FA, Landeziel über die Challenge, Google
  ohne erzwungenes remember, magic-login ohne guest für eigene Links
- B-10: eigener Magic-Link-Zweck image_confirmation (Migration)
- K-07: legalAcceptanceVersions #[Locked], Versionswechsel beim Absenden
  wird abgelehnt, Gate-Abfragen je Request zwischengespeichert

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:09:00 +00:00

259 lines
12 KiB
PHP

<?php
use App\Enums\CompanyAddressClass;
use App\Enums\Portal;
use App\Models\ActivityEvent;
use App\Models\Category;
use App\Models\Company;
use App\Models\CompanyEmailGroup;
use App\Models\PressRelease;
use App\Models\User;
use App\Services\Auth\ContactAccessService;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\Queue;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
/**
* Laufende Zuordnung neuer API-Firmen zum bestehenden Newsroom über die
* Firmen-E-Mail (Roadmap 1a.4). connektar legt je Meldung eine Firma an —
* ohne die Zuordnung entstünde je Meldung ein neuer Newsroom.
*/
function assignmentApiUser(Portal $portal = Portal::Businessportal24, bool $allowlisted = true): User
{
test()->seed(RolesAndPermissionsSeeder::class);
config()->set('billing.enforce_booking', false);
config()->set('app.theme', 'portal');
$user = User::factory()->create(['is_active' => true, 'portal' => $portal]);
$user->forceFill(['registration_type' => 'apiuser'])->save();
// Zuordnung zu bestehenden Firmen nur für freigeschaltete Einlieferer
// (Umsetzungsaudit 29.09.2026, R-01) — die Bestandstests laufen als
// freigeschalteter Einlieferer (connektar).
config()->set('newsroom.api_assignment.allowed_user_ids', $allowlisted ? [$user->id] : []);
Sanctum::actingAs($user, ['press-releases:read', 'press-releases:write', 'companies:read']);
return $user;
}
/**
* @param array<string, mixed> $overrides
*/
function postApiCompany(array $overrides = []): int
{
$response = test()->postJson('/service/users/me/companies', [
'company' => array_merge(['name' => 'Muster GmbH', 'email' => 'info@muster.de'], $overrides),
])->assertCreated();
return (int) basename((string) $response->headers->get('Location'));
}
function assignmentGroup(string $email, CompanyAddressClass $class, ?CompanyAddressClass $manual = null): void
{
CompanyEmailGroup::query()->create([
'email' => $email, 'classification' => $class, 'manual_classification' => $manual,
'companies_count' => 30, 'distinct_names' => 30, 'published_companies_count' => 0, 'threshold' => 3, 'classified_at' => now(),
]);
}
test('the same company under the same email is reused instead of created', function () {
/** @var TestCase $this */
$user = assignmentApiUser();
$existing = Company::factory()->create(['email' => 'info@muster.de', 'name' => 'Muster GmbH', 'portal' => 'businessportal24']);
$id = postApiCompany(['email' => 'Info@Muster.de']);
expect($id)->toBe($existing->id)
->and(Company::withoutGlobalScopes()->where('email', 'like', '%muster.de')->count())->toBe(1)
->and($existing->users()->whereKey($user->id)->wherePivot('role', 'member')->wherePivot('granted_via', 'api')->exists())->toBeTrue()
->and(ActivityEvent::query()->where('subject_id', $existing->id)->where('description', 'like', 'API-Firma%')->exists())->toBeTrue();
});
test('a name that differs only by legal form or a typo still matches', function () {
/** @var TestCase $this */
assignmentApiUser();
$existing = Company::factory()->create(['email' => 'info@muster.de', 'name' => 'Muster Unternehmensberatung', 'portal' => 'businessportal24']);
expect(postApiCompany(['name' => 'Muster Unternehmensberatung GmbH & Co. KG']))->toBe($existing->id)
->and(postApiCompany(['name' => 'Muster Unternehmensbertung']))->toBe($existing->id);
});
test('a different name under the same address gets its own company as before', function () {
/** @var TestCase $this */
assignmentApiUser();
Company::factory()->create(['email' => 'presse@agentur.de', 'name' => 'Kunde Eins GmbH', 'portal' => 'businessportal24']);
$id = postApiCompany(['name' => 'Ganz Anderer Kunde AG', 'email' => 'presse@agentur.de']);
expect(Company::withoutGlobalScopes()->find($id)->name)->toBe('Ganz Anderer Kunde AG')
->and(Company::withoutGlobalScopes()->where('email', 'presse@agentur.de')->count())->toBe(2);
});
test('under a never-published address nothing is ever assigned', function () {
/** @var TestCase $this */
assignmentApiUser();
assignmentGroup('yahoo@yahoo.com', CompanyAddressClass::NeverPublished);
$existing = Company::factory()->create(['email' => 'yahoo@yahoo.com', 'name' => 'Muster GmbH', 'portal' => 'businessportal24']);
expect(postApiCompany(['email' => 'yahoo@yahoo.com']))->not->toBe($existing->id);
});
test('a manual decision can turn a never-published address back into a normal one', function () {
/** @var TestCase $this */
assignmentApiUser();
assignmentGroup('presse@pr4you.de', CompanyAddressClass::NeverPublished, manual: CompanyAddressClass::CollectionAddress);
$existing = Company::factory()->create(['email' => 'presse@pr4you.de', 'name' => 'Muster GmbH', 'portal' => 'businessportal24']);
expect(postApiCompany(['email' => 'presse@pr4you.de']))->toBe($existing->id);
});
test('a company on the other portal becomes both and the release lands on the requested portal', function () {
/** @var TestCase $this */
$user = assignmentApiUser(Portal::Presseecho);
$existing = Company::factory()->create(['email' => 'info@muster.de', 'name' => 'Muster GmbH', 'portal' => 'businessportal24']);
$category = Category::factory()->create(['portal' => Portal::Both]);
Queue::fake();
$id = postApiCompany();
expect($id)->toBe($existing->id)
->and($existing->fresh()->portal)->toBe(Portal::Both);
$this->postJson('/service/pressreleases', [
'press_release' => [
'company_id' => $id,
'category_id' => $category->legacy_id ?? $category->id,
'language' => 'de',
'title' => 'Zuordnungs-Test',
'text' => 'Inhalt der Test-Pressemitteilung mit genug Text.',
],
])->assertCreated();
// `both` ist kein Ziel-Portal für eine Meldung — ohne Parameter und
// Domain-Kontext entscheidet das Portal des API-Nutzers.
$release = PressRelease::withoutGlobalScopes()->where('title', 'Zuordnungs-Test')->firstOrFail();
expect($release->portal)->toBe(Portal::Presseecho)
->and($release->company_id)->toBe($existing->id)
->and($release->user_id)->toBe($user->id);
});
test('a slug taken on the other portal prevents widening and falls back to a new company', function () {
/** @var TestCase $this */
assignmentApiUser(Portal::Presseecho);
$existing = Company::factory()->create(['email' => 'info@muster.de', 'name' => 'Muster GmbH', 'portal' => 'businessportal24', 'slug' => 'muster-gmbh']);
Company::factory()->create(['email' => 'anders@fremd.de', 'name' => 'Muster GmbH', 'portal' => 'presseecho', 'slug' => 'muster-gmbh']);
$id = postApiCompany();
expect($id)->not->toBe($existing->id)
->and($existing->fresh()->portal)->toBe(Portal::Businessportal24);
});
test('a merged-away company is not matched, its survivor is', function () {
/** @var TestCase $this */
assignmentApiUser(Portal::Presseecho);
$survivor = Company::factory()->create(['email' => 'info@muster.de', 'name' => 'Muster GmbH', 'portal' => 'both']);
$gone = Company::factory()->create(['email' => 'info@muster.de', 'name' => 'Muster GmbH', 'portal' => 'presseecho']);
$gone->delete();
expect(postApiCompany())->toBe($survivor->id);
});
test('without an email a company is created as before', function () {
/** @var TestCase $this */
assignmentApiUser();
$existing = Company::factory()->create(['email' => null, 'name' => 'Muster GmbH', 'portal' => 'businessportal24']);
expect(postApiCompany(['email' => null]))->not->toBe($existing->id);
});
/*
* R-01 (Umsetzungsaudit 29.09.2026): Firmenname und Kontaktadresse stehen
* öffentlich im Newsroom. Ohne Freischaltung darf ein fremdes Token darüber
* keine Mitgliedschaft (Schreibrecht auf alle Firmen-PMs) erlangen.
*/
test('a foreign token with matching email and name gets its own company and no membership', function () {
/** @var TestCase $this */
$user = assignmentApiUser(Portal::Presseecho, allowlisted: false);
$existing = Company::factory()->create(['email' => 'info@muster.de', 'name' => 'Muster GmbH', 'portal' => 'businessportal24']);
$id = postApiCompany();
expect($id)->not->toBe($existing->id)
->and($existing->users()->whereKey($user->id)->exists())->toBeFalse()
->and($existing->fresh()->portal)->toBe(Portal::Businessportal24)
->and(Company::withoutGlobalScopes()->find($id)->owner_user_id)->toBe($user->id)
->and(ActivityEvent::query()->where('subject_id', $existing->id)->where('description', 'like', 'API-Firma%')->exists())->toBeFalse();
});
test('a user who already belongs to the company is reused without allowlist and keeps his role', function () {
/** @var TestCase $this */
$user = assignmentApiUser(allowlisted: false);
$existing = Company::factory()->create(['email' => 'info@muster.de', 'name' => 'Muster GmbH', 'portal' => 'businessportal24']);
$existing->users()->attach($user->id, ['role' => 'responsible', 'granted_via' => 'manual']);
expect(postApiCompany())->toBe($existing->id)
->and($existing->users()->whereKey($user->id)->first()->pivot->role)->toBe('responsible')
->and($existing->users()->whereKey($user->id)->first()->pivot->granted_via)->toBe('manual');
});
test('the owner of the company is reused without allowlist and gets no extra pivot', function () {
/** @var TestCase $this */
$user = assignmentApiUser(allowlisted: false);
$existing = Company::factory()->create(['email' => 'info@muster.de', 'name' => 'Muster GmbH', 'portal' => 'businessportal24', 'owner_user_id' => $user->id]);
expect(postApiCompany())->toBe($existing->id)
->and($existing->users()->whereKey($user->id)->exists())->toBeFalse();
});
test('the login sync never revokes an api membership', function () {
/** @var TestCase $this */
$user = assignmentApiUser();
$existing = Company::factory()->create(['email' => 'info@muster.de', 'name' => 'Muster GmbH', 'portal' => 'businessportal24']);
postApiCompany();
app(ContactAccessService::class)->syncMembershipsForEmail($user->fresh());
expect($existing->users()->whereKey($user->id)->wherePivot('granted_via', 'api')->exists())->toBeTrue();
});
test('the review command lists api assignments and removes foreign ones only with --remove', function () {
/** @var TestCase $this */
$allowed = assignmentApiUser();
$existing = Company::factory()->create(['email' => 'info@muster.de', 'name' => 'Muster GmbH', 'portal' => 'businessportal24']);
postApiCompany();
// Altfall vor dem Fix: ein fremdes Konto wurde per API-Zuordnung Mitglied.
$stranger = User::factory()->create();
$existing->users()->attach($stranger->id, ['role' => 'member', 'granted_via' => 'manual']);
ActivityEvent::query()->create([
'type' => 'correction', 'occurred_at' => now(), 'user_id' => $stranger->id, 'actor_label' => 'api',
'subject_type' => $existing->getMorphClass(), 'subject_id' => $existing->id,
'description' => 'API-Firma „Muster GmbH“ dem bestehenden Newsroom #'.$existing->id.' „Muster GmbH“ zugeordnet (info@muster.de)',
'meta' => ['api_newsroom_assignment' => true],
]);
// Mitglied schon vor der Zuordnung: bleibt unangetastet.
$veteran = User::factory()->create();
$existing->users()->attach($veteran->id, ['role' => 'member', 'granted_via' => 'manual', 'created_at' => now()->subMonth(), 'updated_at' => now()->subMonth()]);
ActivityEvent::query()->create([
'type' => 'correction', 'occurred_at' => now(), 'user_id' => $veteran->id, 'actor_label' => 'api',
'subject_type' => $existing->getMorphClass(), 'subject_id' => $existing->id,
'description' => 'API-Firma „Muster GmbH“ dem bestehenden Newsroom #'.$existing->id.' „Muster GmbH“ zugeordnet (info@muster.de)',
'meta' => ['api_newsroom_assignment' => true],
]);
$this->artisan('company-memberships:api-assignments')
->expectsOutputToContain('2 über die API-Zuordnung entstandene Mitgliedschaft(en), davon 1 ohne Freischaltung')
->assertSuccessful();
expect($existing->users()->whereKey($stranger->id)->exists())->toBeTrue();
$this->artisan('company-memberships:api-assignments', ['--remove' => true, '--force' => true])->assertSuccessful();
expect($existing->users()->whereKey($stranger->id)->exists())->toBeFalse()
->and($existing->users()->whereKey($allowed->id)->exists())->toBeTrue()
->and($existing->users()->whereKey($veteran->id)->exists())->toBeTrue();
});