presseportale/tests/Feature/Api/LegacyApiCompatibilityTest.php
Kevin Adametz 39e13d6d84 security: Host-Whitelist & Legacy-API-Key-Übergabe härten (H5, M11)
- trustHosts erlaubt nur die konfigurierten Domains (plus localhost/IP
  für Health-Checks) und verwirft fremde Host-Header, sodass
  X-Forwarded-Host keine URL-Generierung mehr vergiften kann (H5).
- Legacy-API-Keys nur noch per Header (Bearer/X-ApiKey); die alte
  Query-/Body-Übergabe wird nicht mehr zur Auth akzeptiert, erhält aber
  weiterhin den 410-Migrationshinweis statt eines opaken 401 (M11).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 13:48:28 +00:00

226 lines
9.1 KiB
PHP

<?php
use App\Enums\Portal;
use App\Enums\UserPaymentOptionStatus;
use App\Models\Category;
use App\Models\Company;
use App\Models\PaymentOption;
use App\Models\PressRelease;
use App\Models\User;
use App\Models\UserPaymentOption;
use App\Services\Api\LegacyApiKeyImporter;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Laravel\Sanctum\PersonalAccessToken;
use Tests\TestCase;
/**
* Übernahme bestehender Legacy-API-Integrationen (06.07.2026): importierte
* api_keys bleiben gültig, alte Übergabeformen (X-ApiKey/api_key) und die
* dokumentierten /service/…-Pfade funktionieren weiter.
*/
function eligibleLegacyApiUser(int $legacyId = 4711): User
{
$user = User::factory()->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('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');
});