presseportale/tests/Feature/ApiAccessSecurityAndLoggingTest.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

80 lines
3.1 KiB
PHP

<?php
use App\Models\ApiUsageLog;
use App\Models\User;
use Tests\TestCase;
test('api requests are logged without storing bearer token contents', function () {
/** @var TestCase $this */
$user = User::factory()->create(['is_active' => true]);
$plainTextToken = $user->createToken('API Client', ['press-releases:read'])->plainTextToken;
$tokenId = $user->tokens()->value('id');
$this->withHeader('Authorization', "Bearer {$plainTextToken}")
->getJson('/api/v1/categories')
->assertOk();
$log = ApiUsageLog::query()->firstOrFail();
expect($log->user_id)->toBe($user->id)
->and($log->personal_access_token_id)->toBe($tokenId)
->and($log->method)->toBe('GET')
->and($log->path)->toBe('/api/v1/categories')
->and($log->status_code)->toBe(200)
->and(json_encode($log->toArray()))->not->toContain($plainTextToken);
});
test('legacy api keys in query parameters are rejected and redacted from logs', function () {
/** @var TestCase $this */
// Query-Keys werden nicht für die Auth akzeptiert, erhalten aber den
// 410-Migrationshinweis (statt eines opaken 401) und bleiben aus den Logs.
$this->getJson('/api/v1/categories?api_key=legacy-secret')
->assertStatus(410);
$log = ApiUsageLog::query()->firstOrFail();
expect($log->user_id)->toBeNull()
->and($log->personal_access_token_id)->toBeNull()
->and($log->path)->toBe('/api/v1/categories')
->and($log->status_code)->toBe(410)
->and(json_encode($log->toArray()))->not->toContain('legacy-secret');
});
test('inactive users cannot use existing api tokens', function () {
/** @var TestCase $this */
$user = User::factory()->create(['is_active' => false]);
$plainTextToken = $user->createToken('Old API Client', ['press-releases:read'])->plainTextToken;
$this->withHeader('Authorization', "Bearer {$plainTextToken}")
->getJson('/api/v1/categories')
->assertForbidden()
->assertJsonPath('message', 'API access is disabled for inactive users.');
expect(ApiUsageLog::query()->value('status_code'))->toBe(403);
});
test('api rate limit is scoped to each personal access token', function () {
/** @var TestCase $this */
$user = User::factory()->create(['is_active' => true]);
$firstToken = $user->createToken('Busy Client', ['press-releases:read'])->plainTextToken;
$secondToken = $user->createToken('Other Client', ['press-releases:read'])->plainTextToken;
expect(strtok($firstToken, '|'))->not->toBe(strtok($secondToken, '|'));
for ($request = 1; $request <= 60; $request++) {
$this->withHeader('Authorization', "Bearer {$firstToken}")
->getJson('/api/v1/categories')
->assertOk();
}
$this->withHeader('Authorization', "Bearer {$firstToken}")
->getJson('/api/v1/categories')
->assertStatus(429)
->assertJsonPath('message', 'API rate limit exceeded.');
$this->flushHeaders();
$this->withHeader('Authorization', "Bearer {$secondToken}")
->getJson('/api/v1/categories')
->assertOk();
});