create([ 'is_active' => true, 'portal' => Portal::Businessportal24, 'legacy_portal' => 'businessportal24', 'legacy_id' => $legacyId, ]); // Aktiver Zahlungsstatus → API-berechtigt (ApiAccessEligibilityService). UserPaymentOption::factory()->create([ 'user_id' => $user->id, 'payment_option_id' => PaymentOption::factory()->create()->id, 'status' => UserPaymentOptionStatus::Grandfathered->value, 'grandfathered_until' => null, 'current_period_start' => now()->subMonth(), 'current_period_end' => now()->addMonths(11), 'stripe_subscription_id' => null, ]); return $user; } test('the importer turns legacy api keys into sanctum tokens idempotently', function () { $user = eligibleLegacyApiUser(); // Nicht berechtigter Kandidat (inaktiv) und unbekanntes Legacy-Mapping. $inactive = User::factory()->create([ 'is_active' => false, 'legacy_portal' => 'businessportal24', 'legacy_id' => 999, ]); $rows = [ ['legacy_id' => 4711, 'api_key' => 'legacy-key-abc123'], ['legacy_id' => 999, 'api_key' => 'legacy-key-inactive'], ['legacy_id' => 123456, 'api_key' => 'legacy-key-unmapped'], ]; $importer = app(LegacyApiKeyImporter::class); $result = $importer->import('businessportal24', $rows); expect($result['imported'])->toBe(1); expect($result['ineligible'])->toBe(1); expect($result['ineligible_users'])->toBe([$inactive->email]); expect($result['unknown_user'])->toBe(1); $token = PersonalAccessToken::findToken('legacy-key-abc123'); expect($token)->not->toBeNull(); expect($token->tokenable_id)->toBe($user->id); expect($token->name)->toBe(LegacyApiKeyImporter::TOKEN_NAME); expect($token->abilities)->toBe(LegacyApiKeyImporter::ABILITIES); // Re-Run legt keine Duplikate an. $rerun = $importer->import('businessportal24', $rows); expect($rerun['imported'])->toBe(0); expect($rerun['already_present'])->toBe(1); expect(PersonalAccessToken::query()->where('token', hash('sha256', 'legacy-key-abc123'))->count())->toBe(1); }); test('the importer resolves email-merged users via the legacy import map', function () { // E-Mail-Merge-Fall: Der Kunde existierte in beiden Altportalen; der // User trägt nur das zuletzt importierte Portal (businessportal24) in // legacy_portal/legacy_id. Der Presseecho-Schlüssel muss trotzdem über // die legacy_import_map seinen Besitzer finden. $user = eligibleLegacyApiUser(); LegacyImportMap::query()->create([ 'legacy_portal' => 'presseecho', 'legacy_table' => 'sf_guard_user', 'legacy_id' => 555, 'target_table' => 'users', 'target_id' => $user->id, 'imported_at' => now(), ]); $result = app(LegacyApiKeyImporter::class)->import('presseecho', [ ['legacy_id' => 555, 'api_key' => 'legacy-key-presseecho'], ]); expect($result['imported'])->toBe(1); expect($result['unknown_user'])->toBe(0); $token = PersonalAccessToken::findToken('legacy-key-presseecho'); expect($token)->not->toBeNull(); expect($token->tokenable_id)->toBe($user->id); }); test('an imported legacy key authenticates only via legacy headers', function () { /** @var TestCase $this */ eligibleLegacyApiUser(); app(LegacyApiKeyImporter::class)->import('businessportal24', [ ['legacy_id' => 4711, 'api_key' => 'legacy-key-abc123'], ]); // Dokumentierter Alt-Header der Portale: X-ApiKey. $this->getJson('/api/v1/categories', ['X-ApiKey' => 'legacy-key-abc123']) ->assertOk(); // Verbreitete Header-Schreibvariante bleibt kompatibel. $this->getJson('/api/v1/categories', ['X-Api-Key' => 'legacy-key-abc123']) ->assertOk(); }); test('legacy keys in query or body parameters are rejected', function () { /** @var TestCase $this */ eligibleLegacyApiUser(); app(LegacyApiKeyImporter::class)->import('businessportal24', [ ['legacy_id' => 4711, 'api_key' => 'legacy-key-abc123'], ]); // Schlüssel in URL oder Body werden nicht mehr als Zugangsdaten akzeptiert, // erhalten aber weiterhin den Migrationshinweis (410) statt eines opaken 401, // damit Alt-Integrationen den Umstieg auf Header/Bearer erkennen. $this->getJson('/api/v1/categories?api_key=legacy-key-abc123') ->assertStatus(410) ->assertJsonStructure(['message', 'migration_url', 'docs_url']); $this->postJson('/service/pressreleases', [ 'api_key' => 'legacy-key-abc123', ]) ->assertStatus(410) ->assertJsonStructure(['message', 'migration_url', 'docs_url']); }); test('unknown legacy keys still get the 410 migration hint', function () { /** @var TestCase $this */ $this->getJson('/api/v1/categories', ['X-ApiKey' => 'gibt-es-nicht']) ->assertStatus(410) ->assertJsonStructure(['message', 'migration_url', 'docs_url']); }); test('the documented legacy service paths keep working with an imported key', function () { /** @var TestCase $this */ eligibleLegacyApiUser(); app(LegacyApiKeyImporter::class)->import('businessportal24', [ ['legacy_id' => 4711, 'api_key' => 'legacy-key-abc123'], ]); $this->getJson('/service/pressreleases', ['X-ApiKey' => 'legacy-key-abc123']) ->assertOk(); $this->getJson('/service/users/me/companies', ['X-ApiKey' => 'legacy-key-abc123']) ->assertOk(); }); test('the legacy service paths require authentication', function () { /** @var TestCase $this */ $this->getJson('/service/pressreleases')->assertUnauthorized(); }); test('a read only token cannot write through legacy service endpoints', function () { /** @var TestCase $this */ $user = eligibleLegacyApiUser(); $company = Company::factory()->create(['portal' => Portal::Businessportal24]); $user->companies()->attach($company->id, ['role' => 'owner']); $pressRelease = PressRelease::factory()->create([ 'user_id' => $user->id, 'company_id' => $company->id, 'portal' => Portal::Businessportal24, ]); $plainTextToken = $user->createToken('Read only', [ 'press-releases:read', 'companies:read', ])->plainTextToken; $headers = ['Authorization' => "Bearer {$plainTextToken}"]; $companyCount = Company::withoutGlobalScopes()->count(); $contactCount = $company->contacts()->count(); $pressReleaseCount = PressRelease::withoutGlobalScopes()->count(); $imageCount = $pressRelease->images()->count(); $this->postJson('/service/pressreleases', [], $headers)->assertForbidden(); $this->postJson('/service/users/me/companies', [], $headers)->assertForbidden(); $this->postJson("/service/users/me/companies/{$company->id}/contacts", [], $headers)->assertForbidden(); $this->postJson("/service/pressreleases/{$pressRelease->id}/images", [], $headers)->assertForbidden(); expect(Company::withoutGlobalScopes()->count())->toBe($companyCount) ->and($company->contacts()->count())->toBe($contactCount) ->and(PressRelease::withoutGlobalScopes()->count())->toBe($pressReleaseCount) ->and($pressRelease->images()->count())->toBe($imageCount); }); test('retired legacy service endpoints answer with a helpful 410 instead of 404', function () { /** @var TestCase $this */ // Company-Create ist seit 10.07. ein echter Paritaets-Endpoint (S1); // als weiterhin abgeschalteter Alt-Pfad dient das Einzelbild-GET. $this->getJson('/service/pressreleases/1/images/1') ->assertStatus(410) ->assertJsonStructure(['message', 'account_url', 'docs_url']); $this->getJson('/service/irgendwas')->assertStatus(410); }); test('legacy service calls default the ai declaration instead of breaking old clients', function () { /** @var TestCase $this */ Storage::fake('local'); $user = eligibleLegacyApiUser(); app(LegacyApiKeyImporter::class)->import('businessportal24', [ ['legacy_id' => 4711, 'api_key' => 'legacy-key-abc123'], ]); $company = Company::factory()->create(['portal' => Portal::Businessportal24]); $user->companies()->attach($company->id, ['role' => 'owner']); $category = Category::factory()->create(['portal' => Portal::Businessportal24]); // Store ohne KI-Felder (Alt-Client): Default "ohne generative KI erstellt" // (Entscheidung 10.07.2026 — das API-Kunden-Mailing weist darauf hin). Queue::fake(); $response = $this->postJson('/service/pressreleases', [ 'press_release' => [ 'company_id' => $company->id, 'category_id' => $category->id, 'language' => 'de', 'title' => 'Alt-Client-Meldung', 'text' => 'Inhalt aus einer bestehenden Integration.', ], ], ['X-ApiKey' => 'legacy-key-abc123'])->assertCreated(); $pressRelease = PressRelease::withoutGlobalScopes()->find((int) basename((string) $response->headers->get('Location'))); expect($pressRelease->ai_text_declaration?->value)->toBe('none') ->and($pressRelease->ai_declaration_confirmed_at)->not->toBeNull(); // Bild-Upload ohne KI-Feld: Default Originalfoto. $this->postJson("/service/pressreleases/{$pressRelease->id}/images", [ 'press_release_image' => [ 'image' => UploadedFile::fake()->image('alt-client.jpg'), ], ], ['X-ApiKey' => 'legacy-key-abc123'])->assertCreated(); expect($pressRelease->images()->first()->ai_declaration?->value)->toBe('original'); });