Bisher blieb ein einmal erstellter Token gültig, auch wenn Abo bzw. Buchung längst beendet waren. EnsureApiAccessIsBooked prüft jetzt bei jeder Anfrage (v1 und /service/…-Altpfade), ob das Konto API-Zugang hat (ApiAccessEligibilityService::hasApiAccess), sonst 403. Die Tokens bleiben erhalten und funktionieren nach erneuter Buchung wieder. Kunden können beim Erstellen optional ein Ablaufdatum setzen (bis 5 Jahre); die Token-Liste zeigt „Gültig bis“ und einen Hinweis, wenn der Zugang pausiert ist. sanctum:prune-expired räumt abgelaufene Tokens nach 30 Tagen täglich ab. API-Doku (public/api/v1.yml) ergänzt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
134 lines
5.3 KiB
PHP
134 lines
5.3 KiB
PHP
<?php
|
|
|
|
use App\Enums\UserPaymentOptionStatus;
|
|
use App\Models\ApiUsageLog;
|
|
use App\Models\User;
|
|
use App\Models\UserPaymentOption;
|
|
use Tests\TestCase;
|
|
|
|
test('api requests are logged without storing bearer token contents', function () {
|
|
/** @var TestCase $this */
|
|
$user = grantApiAccess(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 = grantApiAccess(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();
|
|
});
|
|
|
|
/*
|
|
* 08.10.2026: API-Zugang nur mit laufender Buchung — bei jedem Aufruf, nicht
|
|
* nur beim Erzeugen des Tokens. Tokens bleiben erhalten und funktionieren
|
|
* wieder, sobald erneut gebucht ist.
|
|
*/
|
|
test('tokens stop working once the booking has ended and work again after rebooking', function (string $path) {
|
|
/** @var TestCase $this */
|
|
$user = grantApiAccess(User::factory()->create(['is_active' => true]));
|
|
$plainTextToken = $user->createToken('Integration', ['press-releases:read'])->plainTextToken;
|
|
|
|
$this->withToken($plainTextToken)->getJson($path)->assertSuccessful();
|
|
|
|
UserPaymentOption::query()->where('user_id', $user->id)->update([
|
|
'status' => UserPaymentOptionStatus::Cancelled->value,
|
|
'current_period_end' => now()->subDay(),
|
|
]);
|
|
|
|
$this->withToken($plainTextToken)->getJson($path)
|
|
->assertForbidden()
|
|
->assertJsonPath('message', 'API access requires an active subscription or booking.');
|
|
|
|
grantApiAccess($user);
|
|
|
|
$this->withToken($plainTextToken)->getJson($path)->assertSuccessful();
|
|
expect($user->tokens()->count())->toBe(1);
|
|
})->with([
|
|
'api v1' => '/api/v1/categories',
|
|
'legacy service alias' => '/service/pressreleases',
|
|
]);
|
|
|
|
test('tokens of accounts without any booking are rejected', function () {
|
|
/** @var TestCase $this */
|
|
$user = User::factory()->create(['is_active' => true]);
|
|
$plainTextToken = $user->createToken('Ohne Buchung', ['press-releases:read'])->plainTextToken;
|
|
|
|
$this->withToken($plainTextToken)->getJson('/api/v1/categories')->assertForbidden();
|
|
});
|
|
|
|
test('expired tokens are rejected', function () {
|
|
/** @var TestCase $this */
|
|
$user = grantApiAccess(User::factory()->create(['is_active' => true]));
|
|
$plainTextToken = $user->createToken('Befristet', ['press-releases:read'], now()->addDay())->plainTextToken;
|
|
|
|
$this->withToken($plainTextToken)->getJson('/api/v1/categories')->assertSuccessful();
|
|
|
|
$this->travel(2)->days();
|
|
// Der Test-Container behält den einmal aufgelösten Nutzer über Anfragen hinweg.
|
|
app('auth')->forgetGuards();
|
|
|
|
$this->withToken($plainTextToken)->getJson('/api/v1/categories')->assertUnauthorized();
|
|
});
|