diff --git a/app/Http/Middleware/EnsureApiAccessIsBooked.php b/app/Http/Middleware/EnsureApiAccessIsBooked.php new file mode 100644 index 00000000..904efef0 --- /dev/null +++ b/app/Http/Middleware/EnsureApiAccessIsBooked.php @@ -0,0 +1,38 @@ +user(); + + if ($user instanceof User && ! $this->eligibility->hasApiAccess($user)) { + return response()->json([ + 'message' => 'API access requires an active subscription or booking.', + ], 403); + } + + return $next($request); + } +} diff --git a/app/Services/Api/ApiAccessEligibilityService.php b/app/Services/Api/ApiAccessEligibilityService.php index 647f9378..b5e5a3c9 100644 --- a/app/Services/Api/ApiAccessEligibilityService.php +++ b/app/Services/Api/ApiAccessEligibilityService.php @@ -5,14 +5,24 @@ namespace App\Services\Api; use App\Models\User; /** - * Token-Gate des hybriden Rechnungsmodells: freigeschaltet ist, wer ein + * API-Gate des hybriden Rechnungsmodells: freigeschaltet ist, wer ein * aktives Stripe-Abo (Cashier, STR-Kreis), eine laufende Zahlungsoption * (MAN-Kreis/Bestandsschutz) oder eine bezahlte letzte Legacy-Rechnung hat — - * spiegelbildlich zu User::hasActiveBooking(). + * spiegelbildlich zu User::hasActiveBooking(). Geprüft beim Erzeugen eines + * Tokens und bei jedem API-Aufruf. */ class ApiAccessEligibilityService { public function canCreateToken(User $user): bool + { + return $this->hasApiAccess($user); + } + + /** + * Gilt beim Erzeugen eines Tokens und bei jedem API-Aufruf + * (EnsureApiAccessIsBooked). + */ + public function hasApiAccess(User $user): bool { return $this->denialReason($user) === null; } diff --git a/public/api/v1.yml b/public/api/v1.yml index 195e6c42..8b2b11d6 100644 --- a/public/api/v1.yml +++ b/public/api/v1.yml @@ -13,6 +13,11 @@ info: Token trägt Berechtigungen (Abilities), die den Zugriff auf die Endpunkte steuern. Tokens werden im Publisher-Konto unter „API-Tokens" selbst erstellt und widerrufen (Voraussetzung: aktive Buchung). + Die Buchung wird bei jeder Anfrage geprüft: Endet sie, antwortet die + API mit 403 („API access requires an active subscription or booking."), + bis wieder gebucht ist — die Tokens selbst bleiben erhalten. Optional + lässt sich beim Erstellen ein Ablaufdatum setzen; danach antwortet die + API für diesen Token mit 401. Rate-Limit: 60 Anfragen pro Minute je Token (Header X-RateLimit-Limit / X-RateLimit-Remaining, bei 429 zusätzlich Retry-After). diff --git a/resources/views/livewire/customer/tokens.blade.php b/resources/views/livewire/customer/tokens.blade.php index 63866f04..f09ba7ee 100644 --- a/resources/views/livewire/customer/tokens.blade.php +++ b/resources/views/livewire/customer/tokens.blade.php @@ -3,6 +3,7 @@ use App\Actions\Admin\UserImpersonation; use App\Livewire\Concerns\ConfirmsIdentity; use App\Services\Api\ApiAccessEligibilityService; +use Illuminate\Support\Carbon; use Illuminate\Validation\Rule; use Livewire\Attributes\Layout; use Livewire\Attributes\Title; @@ -14,6 +15,12 @@ new #[Layout('components.layouts.app'), Title('API-Tokens')] class extends Compo public string $tokenName = ''; + /** + * Optionales Ablaufdatum (08.10.2026): leer = gültig, solange gebucht + * ist. Gesetzt endet der Token am Ende dieses Tages. + */ + public ?string $tokenExpiresOn = null; + /** @var list */ public array $selectedAbilities = ['press-releases:read']; @@ -49,6 +56,7 @@ new #[Layout('components.layouts.app'), Title('API-Tokens')] class extends Compo $validated = $this->validate([ 'tokenName' => ['required', 'string', 'max:80'], + 'tokenExpiresOn' => ['nullable', 'date_format:Y-m-d', 'after:today', 'before_or_equal:'.now()->addYears(5)->toDateString()], 'selectedAbilities' => ['required', 'array', 'min:1'], 'selectedAbilities.*' => ['required', 'string', Rule::in(array_keys(self::ABILITIES))], ]); @@ -60,12 +68,16 @@ new #[Layout('components.layouts.app'), Title('API-Tokens')] class extends Compo $token = auth()->user()->createToken( $validated['tokenName'], $validated['selectedAbilities'], + filled($validated['tokenExpiresOn'] ?? null) + ? Carbon::createFromFormat('Y-m-d', $validated['tokenExpiresOn'])->endOfDay() + : null, ); $this->plainTextToken = $token->plainTextToken; $this->notification = __('Token wurde erstellt. Bitte kopieren Sie ihn jetzt, er wird später nicht erneut angezeigt.'); $this->eligibilityMessage = null; $this->tokenName = ''; + $this->tokenExpiresOn = null; $this->selectedAbilities = ['press-releases:read']; } @@ -92,6 +104,7 @@ new #[Layout('components.layouts.app'), Title('API-Tokens')] class extends Compo 'abilityOptions' => self::ABILITIES, 'securityProofKind' => $this->securityProofKind(auth()->user()), 'canCreateApiToken' => $denialReason === null && ! $isImpersonating, + 'hasApiAccess' => $denialReason === null, 'canRevokeApiTokens' => ! $isImpersonating, 'apiTokenDenialReason' => $isImpersonating ? __(self::IMPERSONATION_DENIAL_MESSAGE) @@ -99,7 +112,7 @@ new #[Layout('components.layouts.app'), Title('API-Tokens')] class extends Compo 'tokens' => auth()->user() ->tokens() ->latest() - ->get(['id', 'name', 'abilities', 'last_used_at', 'created_at']), + ->get(['id', 'name', 'abilities', 'last_used_at', 'created_at', 'expires_at']), ]; } @@ -151,7 +164,12 @@ new #[Layout('components.layouts.app'), Title('API-Tokens')] class extends Compo
-
{{ $eligibilityMessage ?? $apiTokenDenialReason }}
+
+ {{ $eligibilityMessage ?? $apiTokenDenialReason }} + @if ($tokens->isNotEmpty() && ! $hasApiAccess) + {{ __('Ihre vorhandenen Tokens sind bis dahin gesperrt. Sie bleiben gespeichert und funktionieren wieder, sobald eine Buchung aktiv ist.') }} + @endif +
@endif @@ -189,6 +207,13 @@ new #[Layout('components.layouts.app'), Title('API-Tokens')] class extends Compo + + {{ __('Gültig bis (optional)') }} + + {{ __('Leer lassen: Der Token gilt, solange Ihre Buchung läuft. Mit Datum endet er an diesem Tag um 23:59 Uhr — in jedem Fall spätestens mit dem Ende Ihrer Buchung.') }} + + +
{{ __('Berechtigungen') }}
@@ -228,6 +253,7 @@ new #[Layout('components.layouts.app'), Title('API-Tokens')] class extends Compo {{ __('Berechtigungen') }} {{ __('Erstellt') }} {{ __('Zuletzt genutzt') }} + {{ __('Gültig bis') }} {{ __('Aktionen') }} @@ -253,6 +279,15 @@ new #[Layout('components.layouts.app'), Title('API-Tokens')] class extends Compo {{ $token->last_used_at?->format('d.m.Y H:i') ?? __('Nie') }} + + @if ($token->expires_at?->isPast()) + {{ __('Abgelaufen am :date', ['date' => $token->expires_at->format('d.m.Y')]) }} + @elseif ($token->expires_at) + {{ $token->expires_at->format('d.m.Y') }} + @else + {{ __('Solange gebucht') }} + @endif + @empty - +
diff --git a/routes/api.php b/routes/api.php index ea63a517..1fcdd47a 100644 --- a/routes/api.php +++ b/routes/api.php @@ -6,12 +6,13 @@ use App\Http\Controllers\Api\V1\LegalAcceptanceController; use App\Http\Controllers\Api\V1\NewsletterSubscriptionController; use App\Http\Controllers\Api\V1\PressReleaseController; use App\Http\Controllers\Api\V1\PressReleaseImageController; +use App\Http\Middleware\EnsureApiAccessIsBooked; use App\Http\Middleware\EnsureApiTokenRateLimit; use App\Http\Middleware\EnsureApiUserIsActive; use Illuminate\Support\Facades\Route; Route::prefix('v1') - ->middleware(['auth:sanctum', EnsureApiUserIsActive::class, EnsureApiTokenRateLimit::class]) + ->middleware(['auth:sanctum', EnsureApiUserIsActive::class, EnsureApiAccessIsBooked::class, EnsureApiTokenRateLimit::class]) // Controller erwarten int-IDs — nicht-numerische Werte (z. B. vergessene // Platzhalter wie „PM_ID") sollen 404 statt TypeError/500 ergeben. ->where(['pressRelease' => '[0-9]+', 'pressReleaseImage' => '[0-9]+', 'company' => '[0-9]+']) diff --git a/routes/console.php b/routes/console.php index 17482f7a..4dd80f3b 100644 --- a/routes/console.php +++ b/routes/console.php @@ -261,3 +261,12 @@ Schedule::command('queue:prune-failed', ['--hours=720']) ->withoutOverlapping(30) ->runInBackground() ->onFailure($alertOnFailure('queue:prune-failed')); + +// Abgelaufene API-Tokens (optionales Ablaufdatum, 08.10.2026) 30 Tage nach +// Ablauf entfernen (täglich 04:10). Bis dahin zeigt die Token-Seite sie als +// „abgelaufen“ an; Sanctum lehnt sie ab dem Ablauf ohnehin ab. +Schedule::command('sanctum:prune-expired', ['--hours=720']) + ->dailyAt('04:10') + ->withoutOverlapping(30) + ->runInBackground() + ->onFailure($alertOnFailure('sanctum:prune-expired')); diff --git a/routes/legacy-api.php b/routes/legacy-api.php index fb65374a..f50d473f 100644 --- a/routes/legacy-api.php +++ b/routes/legacy-api.php @@ -7,6 +7,7 @@ use App\Http\Controllers\Api\Legacy\LegacyPressReleaseImageController; use App\Http\Controllers\Api\V1\CompanyController; use App\Http\Controllers\Api\V1\PressReleaseController; use App\Http\Controllers\Api\V1\PressReleaseImageController; +use App\Http\Middleware\EnsureApiAccessIsBooked; use App\Http\Middleware\EnsureApiTokenRateLimit; use App\Http\Middleware\EnsureApiUserIsActive; use Illuminate\Support\Facades\Route; @@ -31,7 +32,7 @@ use Illuminate\Support\Facades\Route; Route::prefix('service') ->name('legacy-service.') - ->middleware(['auth:sanctum', EnsureApiUserIsActive::class, EnsureApiTokenRateLimit::class]) + ->middleware(['auth:sanctum', EnsureApiUserIsActive::class, EnsureApiAccessIsBooked::class, EnsureApiTokenRateLimit::class]) // Nicht-numerische IDs fallen in den 410-Catch-all statt auf einen // TypeError im Controller zu laufen. ->where(['pressRelease' => '[0-9]+', 'company' => '[0-9]+']) diff --git a/tests/Feature/Api/ApiNewsroomAssignmentTest.php b/tests/Feature/Api/ApiNewsroomAssignmentTest.php index 84a87c4e..00b1caf6 100644 --- a/tests/Feature/Api/ApiNewsroomAssignmentTest.php +++ b/tests/Feature/Api/ApiNewsroomAssignmentTest.php @@ -11,7 +11,6 @@ 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; /** @@ -33,7 +32,7 @@ function assignmentApiUser(Portal $portal = Portal::Businessportal24, bool $allo // 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']); + apiActingAs($user, ['press-releases:read', 'press-releases:write', 'companies:read']); return $user; } diff --git a/tests/Feature/Api/ApiPortalResolutionTest.php b/tests/Feature/Api/ApiPortalResolutionTest.php index 291b7bc7..65d10bac 100644 --- a/tests/Feature/Api/ApiPortalResolutionTest.php +++ b/tests/Feature/Api/ApiPortalResolutionTest.php @@ -7,7 +7,6 @@ use App\Models\PressRelease; use App\Models\User; use Database\Seeders\RolesAndPermissionsSeeder; use Illuminate\Support\Facades\Queue; -use Laravel\Sanctum\Sanctum; use Tests\TestCase; /** @@ -28,7 +27,7 @@ function portalResolutionApiUser(Portal $portal = Portal::Businessportal24): Use ]); $user->forceFill(['registration_type' => 'apiuser'])->save(); - Sanctum::actingAs($user, [ + apiActingAs($user, [ 'press-releases:read', 'press-releases:write', 'companies:read', @@ -150,7 +149,7 @@ test('v1 press release creation resolves the portal from parameter and domain', $category = Category::factory()->withTranslations()->create(['portal' => Portal::Both]); $user->companies()->attach($company->id, ['role' => 'owner']); - Sanctum::actingAs($user, ['press-releases:read', 'press-releases:write']); + apiActingAs($user, ['press-releases:read', 'press-releases:write']); config()->set('app.theme', 'presseecho'); @@ -179,7 +178,7 @@ test('v1 press release creation rejects the portal value both', function () { $category = Category::factory()->withTranslations()->create(['portal' => Portal::Both]); $user->companies()->attach($company->id, ['role' => 'owner']); - Sanctum::actingAs($user, ['press-releases:read', 'press-releases:write']); + apiActingAs($user, ['press-releases:read', 'press-releases:write']); $this->postJson('/api/v1/press-releases', [ 'portal' => 'both', @@ -204,7 +203,7 @@ test('v1 update keeps the portal unless an explicit portal parameter is sent', f ->for(Category::factory()->withTranslations()->create(['portal' => Portal::Both])) ->create(['user_id' => $user->id, 'company_id' => $company->id]); - Sanctum::actingAs($user, ['press-releases:read', 'press-releases:write']); + apiActingAs($user, ['press-releases:read', 'press-releases:write']); // Update über die "falsche" Domain verschiebt die PM nicht … config()->set('app.theme', 'presseecho'); diff --git a/tests/Feature/Api/LegacyServiceParityTest.php b/tests/Feature/Api/LegacyServiceParityTest.php index fad67ebd..e1149a25 100644 --- a/tests/Feature/Api/LegacyServiceParityTest.php +++ b/tests/Feature/Api/LegacyServiceParityTest.php @@ -15,7 +15,6 @@ use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Storage; -use Laravel\Sanctum\Sanctum; use Tests\TestCase; /** @@ -37,7 +36,7 @@ function parityApiUser(bool $immediatePublication = true): User $user->givePermissionTo('api:immediate-publication'); } - Sanctum::actingAs($user, [ + apiActingAs($user, [ 'press-releases:read', 'press-releases:write', 'press-release-images:write', diff --git a/tests/Feature/Api/V1/CustomerDataApiTest.php b/tests/Feature/Api/V1/CustomerDataApiTest.php index dacc9eb6..14dc47a5 100644 --- a/tests/Feature/Api/V1/CustomerDataApiTest.php +++ b/tests/Feature/Api/V1/CustomerDataApiTest.php @@ -5,7 +5,6 @@ use App\Models\Category; use App\Models\Company; use App\Models\NewsletterSubscription; use App\Models\User; -use Laravel\Sanctum\Sanctum; use Tests\TestCase; test('legacy api keys in query parameters are not accepted but get the migration hint', function () { @@ -24,7 +23,7 @@ test('api user can read only own companies', function () { $otherCompany = Company::factory()->businessportal24()->create(['name' => 'Fremde Firma']); $user->companies()->attach($ownCompany->id, ['role' => 'owner']); - Sanctum::actingAs($user, ['companies:read']); + apiActingAs($user, ['companies:read']); $this->getJson('/api/v1/companies') ->assertOk() @@ -40,7 +39,7 @@ test('api user can list categories with press release read ability', function () $user = User::factory()->create(); Category::factory()->withTranslations()->create(['portal' => Portal::Both]); - Sanctum::actingAs($user, ['press-releases:read']); + apiActingAs($user, ['press-releases:read']); $this->getJson('/api/v1/categories') ->assertOk() @@ -56,7 +55,7 @@ test('api user can subscribe an email to newsletter', function () { /** @var TestCase $this */ $user = User::factory()->create(); - Sanctum::actingAs($user, ['newsletter:subscribe']); + apiActingAs($user, ['newsletter:subscribe']); $this->postJson('/api/v1/newsletter/subscribe', [ 'portal' => 'presseecho', @@ -86,7 +85,7 @@ test('resubscribing keeps an existing confirmation intact', function () { 'subscribed_at' => now()->subMonth(), 'unsubscribed_at' => now()->subDay(), ]); - Sanctum::actingAs($user, ['newsletter:subscribe']); + apiActingAs($user, ['newsletter:subscribe']); $this->postJson('/api/v1/newsletter/subscribe', [ 'portal' => 'presseecho', @@ -116,7 +115,7 @@ test('an orphaned subscription without owner can be rebound instead of being per ]); $user = User::factory()->create(); - Sanctum::actingAs($user, ['newsletter:subscribe']); + apiActingAs($user, ['newsletter:subscribe']); $this->postJson('/api/v1/newsletter/subscribe', [ 'portal' => 'presseecho', @@ -140,7 +139,7 @@ test('an api user cannot take over another newsletter subscription', function () 'is_confirmed' => true, 'subscribed_at' => now(), ]); - Sanctum::actingAs($attacker, ['newsletter:subscribe']); + apiActingAs($attacker, ['newsletter:subscribe']); $this->postJson('/api/v1/newsletter/subscribe', [ 'portal' => 'presseecho', diff --git a/tests/Feature/Api/V1/PressReleaseApiTest.php b/tests/Feature/Api/V1/PressReleaseApiTest.php index 78e421a0..197e13bc 100644 --- a/tests/Feature/Api/V1/PressReleaseApiTest.php +++ b/tests/Feature/Api/V1/PressReleaseApiTest.php @@ -5,7 +5,6 @@ use App\Models\Category; use App\Models\Company; use App\Models\PressRelease; use App\Models\User; -use Laravel\Sanctum\Sanctum; use Tests\TestCase; test('api user can create list update and delete own press releases', function () { @@ -15,7 +14,7 @@ test('api user can create list update and delete own press releases', function ( $category = Category::factory()->withTranslations()->create(['portal' => Portal::Both]); $user->companies()->attach($company->id, ['role' => 'owner']); - Sanctum::actingAs($user, ['press-releases:read', 'press-releases:write']); + apiActingAs($user, ['press-releases:read', 'press-releases:write']); $createResponse = $this->postJson('/api/v1/press-releases', [ 'company_id' => $company->id, @@ -62,7 +61,7 @@ test('api submitted html text is sanitized before persistence', function () { $category = Category::factory()->withTranslations()->create(['portal' => Portal::Both]); $user->companies()->attach($company->id, ['role' => 'owner']); - Sanctum::actingAs($user, ['press-releases:read', 'press-releases:write']); + apiActingAs($user, ['press-releases:read', 'press-releases:write']); $this->postJson('/api/v1/press-releases', [ 'company_id' => $company->id, @@ -88,7 +87,7 @@ test('api write ability is required to create press releases', function () { $category = Category::factory()->withTranslations()->create(['portal' => Portal::Both]); $user->companies()->attach($company->id, ['role' => 'owner']); - Sanctum::actingAs($user, ['press-releases:read']); + apiActingAs($user, ['press-releases:read']); $this->postJson('/api/v1/press-releases', [ 'company_id' => $company->id, @@ -105,7 +104,7 @@ test('api user cannot access another users press release', function () { $otherUser = User::factory()->create(); $pressRelease = PressRelease::factory()->create(['user_id' => $owner->id]); - Sanctum::actingAs($otherUser, ['press-releases:read']); + apiActingAs($otherUser, ['press-releases:read']); $this->getJson("/api/v1/press-releases/{$pressRelease->id}") ->assertForbidden(); @@ -116,7 +115,7 @@ test('a soft deleted press release disappears from the api (Audit F04)', functio $user = User::factory()->create(); $pressRelease = PressRelease::factory()->create(['user_id' => $user->id, 'title' => 'Bald geloescht']); - Sanctum::actingAs($user, ['press-releases:read', 'press-releases:write']); + apiActingAs($user, ['press-releases:read', 'press-releases:write']); $this->deleteJson("/api/v1/press-releases/{$pressRelease->id}")->assertNoContent(); @@ -135,7 +134,7 @@ test('a soft deleted company is neither listed nor usable for new press releases $user->companies()->attach($company->id, ['role' => 'owner']); $company->delete(); - Sanctum::actingAs($user, ['press-releases:read', 'press-releases:write', 'companies:read']); + apiActingAs($user, ['press-releases:read', 'press-releases:write', 'companies:read']); $this->getJson('/api/v1/companies')->assertOk()->assertJsonMissing(['name' => 'Geloeschte Firma']); $this->getJson("/api/v1/companies/{$company->id}")->assertForbidden(); @@ -154,7 +153,7 @@ test('api list parameters are validated instead of passed through (Audit F11)', $user = User::factory()->create(); PressRelease::factory()->count(3)->create(['user_id' => $user->id]); - Sanctum::actingAs($user, ['press-releases:read', 'companies:read']); + apiActingAs($user, ['press-releases:read', 'companies:read']); $this->getJson('/api/v1/press-releases?'.http_build_query($query))->assertUnprocessable(); $this->getJson('/api/v1/companies?'.http_build_query(array_intersect_key($query, ['per_page' => true])) ?: '/api/v1/companies?per_page=-1') @@ -173,7 +172,7 @@ test('api list accepts the documented parameter range and always limits the quer $user = User::factory()->create(); PressRelease::factory()->count(3)->create(['user_id' => $user->id]); - Sanctum::actingAs($user, ['press-releases:read']); + apiActingAs($user, ['press-releases:read']); $this->getJson('/api/v1/press-releases?per_page=1')->assertOk()->assertJsonCount(1, 'data')->assertJsonPath('meta.per_page', 1); $this->getJson('/api/v1/press-releases?per_page=100&status=draft')->assertOk()->assertJsonCount(3, 'data'); @@ -188,7 +187,7 @@ test('api press release input rejects unsupported languages and inactive or fore $inactive = Category::factory()->withTranslations()->create(['portal' => Portal::Presseecho, 'is_active' => false]); $foreign = Category::factory()->withTranslations()->create(['portal' => Portal::Businessportal24, 'is_active' => true]); - Sanctum::actingAs($user, ['press-releases:read', 'press-releases:write']); + apiActingAs($user, ['press-releases:read', 'press-releases:write']); $payload = fn (array $overrides): array => [ 'company_id' => $company->id, @@ -214,7 +213,7 @@ test('api portal change re-validates the existing category against the new porta $businessOnly = Category::factory()->withTranslations()->create(['portal' => Portal::Businessportal24, 'is_active' => true]); $shared = Category::factory()->withTranslations()->create(['portal' => Portal::Both, 'is_active' => true]); - Sanctum::actingAs($user, ['press-releases:read', 'press-releases:write']); + apiActingAs($user, ['press-releases:read', 'press-releases:write']); $id = $this->postJson('/api/v1/press-releases', [ 'company_id' => $company->id, diff --git a/tests/Feature/Api/V1/PressReleaseImageApiTest.php b/tests/Feature/Api/V1/PressReleaseImageApiTest.php index e0814926..e56ab571 100644 --- a/tests/Feature/Api/V1/PressReleaseImageApiTest.php +++ b/tests/Feature/Api/V1/PressReleaseImageApiTest.php @@ -6,7 +6,6 @@ use App\Models\PressReleaseImage; use App\Models\User; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Storage; -use Laravel\Sanctum\Sanctum; use Tests\TestCase; test('api user can upload list and delete own press release images', function () { @@ -19,7 +18,7 @@ test('api user can upload list and delete own press release images', function () 'status' => PressReleaseStatus::Draft->value, ]); - Sanctum::actingAs($user, ['press-releases:read', 'press-release-images:write']); + apiActingAs($user, ['press-releases:read', 'press-release-images:write']); $uploadResponse = $this->postJson("/api/v1/press-releases/{$pressRelease->id}/images", [ 'image' => UploadedFile::fake()->image('press-release.jpg', 1200, 800)->size(5120), @@ -89,7 +88,7 @@ test('image upload is limited to eight megabytes', function () { 'status' => PressReleaseStatus::Draft->value, ]); - Sanctum::actingAs($user, ['press-release-images:write']); + apiActingAs($user, ['press-release-images:write']); $this->postJson("/api/v1/press-releases/{$pressRelease->id}/images", [ 'image' => UploadedFile::fake()->image('too-large.jpg')->size(8193), @@ -108,7 +107,7 @@ test('api user cannot manage another users press release images', function () { 'status' => PressReleaseStatus::Draft->value, ]); - Sanctum::actingAs($otherUser, ['press-releases:read', 'press-release-images:write']); + apiActingAs($otherUser, ['press-releases:read', 'press-release-images:write']); $this->postJson("/api/v1/press-releases/{$pressRelease->id}/images", [ 'image' => UploadedFile::fake()->image('foreign.jpg'), @@ -125,7 +124,7 @@ test('published press release images cannot be changed via api', function () { 'user_id' => $user->id, ]); - Sanctum::actingAs($user, ['press-release-images:write']); + apiActingAs($user, ['press-release-images:write']); $this->postJson("/api/v1/press-releases/{$pressRelease->id}/images", [ 'image' => UploadedFile::fake()->image('published.jpg'), @@ -144,7 +143,7 @@ test('api image upload requires the per-image ai declaration', function () { 'status' => PressReleaseStatus::Draft->value, ]); - Sanctum::actingAs($user, ['press-release-images:write']); + apiActingAs($user, ['press-release-images:write']); $this->postJson("/api/v1/press-releases/{$pressRelease->id}/images", [ 'image' => UploadedFile::fake()->image('undeklariert.jpg'), diff --git a/tests/Feature/Api/V1/PressReleaseImageRightsV2ApiTest.php b/tests/Feature/Api/V1/PressReleaseImageRightsV2ApiTest.php index b4b71ee0..18625960 100644 --- a/tests/Feature/Api/V1/PressReleaseImageRightsV2ApiTest.php +++ b/tests/Feature/Api/V1/PressReleaseImageRightsV2ApiTest.php @@ -36,7 +36,7 @@ function rightsV2ApiDraft(): array 'status' => PressReleaseStatus::Draft->value, ]); - Sanctum::actingAs($user, ['press-releases:read', 'press-release-images:write']); + apiActingAs($user, ['press-releases:read', 'press-release-images:write']); return compact('user', 'pressRelease'); } @@ -81,7 +81,7 @@ test('a valid upload stores the confirmation with the token and returns the new /** @var TestCase $this */ // Echter Bearer-Token statt Sanctum::actingAs, damit die Token-ID im // Protokoll landet (TransientToken hat keinen Datensatz). - $user = User::factory()->create(); + $user = grantApiAccess(User::factory()->create()); $pressRelease = PressRelease::factory()->create(['user_id' => $user->id, 'status' => PressReleaseStatus::Draft->value]); $plainToken = $user->createToken('connektar', ['press-releases:read', 'press-release-images:write'])->plainTextToken; diff --git a/tests/Feature/Api/V1/PressReleaseSubmitApiTest.php b/tests/Feature/Api/V1/PressReleaseSubmitApiTest.php index f2ce74a4..4491fc49 100644 --- a/tests/Feature/Api/V1/PressReleaseSubmitApiTest.php +++ b/tests/Feature/Api/V1/PressReleaseSubmitApiTest.php @@ -4,6 +4,7 @@ use App\Enums\Portal; use App\Enums\PressReleaseStatus; use App\Models\Category; use App\Models\Company; +use App\Models\LegacyInvoice; use App\Models\Plan; use App\Models\PressRelease; use App\Models\PressReleaseStatusLog; @@ -19,7 +20,7 @@ test('api create always produces a draft and ignores any status input', function $category = Category::factory()->withTranslations()->create(['portal' => Portal::Both]); $user->companies()->attach($company->id, ['role' => 'owner']); - Sanctum::actingAs($user, ['press-releases:write']); + apiActingAs($user, ['press-releases:write']); $this->postJson('/api/v1/press-releases', [ 'company_id' => $company->id, @@ -45,7 +46,7 @@ test('api submit route raises a draft to review without consuming quota and writ 'text' => 'Vollkommen unauffälliger Inhalt.', ]); - Sanctum::actingAs($user, ['press-releases:write']); + apiActingAs($user, ['press-releases:write']); $this->postJson("/api/v1/press-releases/{$pressRelease->id}/submit", ['ai_text_declaration' => 'none']) ->assertOk() @@ -63,7 +64,21 @@ test('api submit responds 402 when the booking gate is enforced', function () { /** @var TestCase $this */ config()->set('billing.enforce_booking', true); + // API-Zugang ohne Buchung: Altkunde mit bezahlter letzter Legacy-Rechnung + // (ApiAccessEligibilityService), aber ohne Abo/Zahlungsoption. $user = User::factory()->create(); + LegacyInvoice::query()->create([ + 'legacy_portal' => 'presseecho', + 'legacy_id' => 8101, + 'user_id' => $user->id, + 'legacy_user_id' => 8101, + 'number' => 'PE-8101', + 'amount_cents' => 9900, + 'total_cents' => 9900, + 'status' => 'paid', + 'invoice_date' => now()->subYear(), + 'imported_at' => now(), + ]); $pressRelease = PressRelease::factory()->create([ 'user_id' => $user->id, 'status' => PressReleaseStatus::Draft->value, @@ -98,6 +113,8 @@ test('api submit responds 422 when the monthly quota is exhausted', function () 'text' => 'Inhalt', ]); + // Das Abo gewährt den API-Zugang; eine zusätzliche Zahlungsoption + // (apiActingAs) würde das Kontingent verfälschen. Sanctum::actingAs($user, ['press-releases:write']); $this->postJson("/api/v1/press-releases/{$pressRelease->id}/submit", ['ai_text_declaration' => 'none']) @@ -118,7 +135,7 @@ test('api submit auto-rejects a press release containing a banned word', functio 'text' => 'Inhalt', ]); - Sanctum::actingAs($user, ['press-releases:write']); + apiActingAs($user, ['press-releases:write']); $this->postJson("/api/v1/press-releases/{$pressRelease->id}/submit", ['ai_text_declaration' => 'none']) ->assertStatus(422); @@ -134,7 +151,7 @@ test('api submit requires the write ability', function () { 'status' => PressReleaseStatus::Draft->value, ]); - Sanctum::actingAs($user, ['press-releases:read']); + apiActingAs($user, ['press-releases:read']); $this->postJson("/api/v1/press-releases/{$pressRelease->id}/submit", ['ai_text_declaration' => 'none']) ->assertForbidden(); @@ -147,7 +164,7 @@ test('api submit rejects a press release already in review', function () { 'user_id' => $user->id, ]); - Sanctum::actingAs($user, ['press-releases:write']); + apiActingAs($user, ['press-releases:write']); $this->postJson("/api/v1/press-releases/{$pressRelease->id}/submit", ['ai_text_declaration' => 'none']) ->assertStatus(409); @@ -162,7 +179,7 @@ test('api user cannot submit another users press release', function () { 'status' => PressReleaseStatus::Draft->value, ]); - Sanctum::actingAs($otherUser, ['press-releases:write']); + apiActingAs($otherUser, ['press-releases:write']); $this->postJson("/api/v1/press-releases/{$pressRelease->id}/submit", ['ai_text_declaration' => 'none']) ->assertForbidden(); @@ -178,7 +195,7 @@ test('api submit requires the ai text declaration (Art. 50 KI-VO)', function () 'status' => PressReleaseStatus::Draft->value, ]); - Sanctum::actingAs($user, ['press-releases:write']); + apiActingAs($user, ['press-releases:write']); $this->postJson("/api/v1/press-releases/{$pressRelease->id}/submit") ->assertUnprocessable() @@ -203,7 +220,7 @@ test('api submit records the declared ai grade as binding confirmation', functio 'text' => 'Vollkommen unauffälliger Inhalt.', ]); - Sanctum::actingAs($user, ['press-releases:write']); + apiActingAs($user, ['press-releases:write']); $this->postJson("/api/v1/press-releases/{$pressRelease->id}/submit", ['ai_text_declaration' => 'assisted']) ->assertOk() @@ -220,7 +237,7 @@ test('api routes respond 404 instead of 500 for non-numeric press release ids', /** @var TestCase $this */ $user = User::factory()->create(); - Sanctum::actingAs($user, ['press-releases:write']); + apiActingAs($user, ['press-releases:write']); // Vergessener Postman-Platzhalter wie „PM_ID" darf nicht als String in // die int-typisierten Controller-Parameter laufen (TypeError/500). diff --git a/tests/Feature/ApiAccessSecurityAndLoggingTest.php b/tests/Feature/ApiAccessSecurityAndLoggingTest.php index e484af51..0318a3ed 100644 --- a/tests/Feature/ApiAccessSecurityAndLoggingTest.php +++ b/tests/Feature/ApiAccessSecurityAndLoggingTest.php @@ -1,12 +1,14 @@ create(['is_active' => true]); + $user = grantApiAccess(User::factory()->create(['is_active' => true])); $plainTextToken = $user->createToken('API Client', ['press-releases:read'])->plainTextToken; $tokenId = $user->tokens()->value('id'); @@ -55,7 +57,7 @@ test('inactive users cannot use existing api tokens', function () { test('api rate limit is scoped to each personal access token', function () { /** @var TestCase $this */ - $user = User::factory()->create(['is_active' => true]); + $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; @@ -78,3 +80,55 @@ test('api rate limit is scoped to each personal access token', function () { ->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(); +}); diff --git a/tests/Feature/CompanyNameNormalizerTest.php b/tests/Feature/CompanyNameNormalizerTest.php index c0911a0b..918988a1 100644 --- a/tests/Feature/CompanyNameNormalizerTest.php +++ b/tests/Feature/CompanyNameNormalizerTest.php @@ -7,7 +7,6 @@ use App\Models\PressRelease; use App\Models\User; use App\Services\Company\CompanyNameNormalizer; use Database\Seeders\RolesAndPermissionsSeeder; -use Laravel\Sanctum\Sanctum; use Tests\TestCase; /** @@ -45,7 +44,7 @@ test('the legacy api stores a clean name and records the correction', function ( config()->set('billing.enforce_booking', false); $user = User::factory()->create(['is_active' => true, 'portal' => Portal::Businessportal24]); $user->forceFill(['registration_type' => 'apiuser'])->save(); - Sanctum::actingAs($user, ['press-releases:write']); + apiActingAs($user, ['press-releases:write']); $response = $this->postJson('/service/users/me/companies', [ 'company' => ['name' => 'Steinreinigung - https://www.bgn.at/steinreinigung', 'email' => 'info@bgn.at'], diff --git a/tests/Feature/CustomerPortalTest.php b/tests/Feature/CustomerPortalTest.php index 50df17e2..da1d6048 100644 --- a/tests/Feature/CustomerPortalTest.php +++ b/tests/Feature/CustomerPortalTest.php @@ -334,3 +334,66 @@ test('creating an api token requires a fresh identity proof (Sicherheitsaudit S- expect($customer->tokens()->count())->toBe(0); }); + +/* + * 08.10.2026: optionales Ablaufdatum für API-Tokens; ohne Datum gilt der + * Token, solange gebucht ist. + */ +test('a customer may give an api token an optional expiry date', function () { + /** @var TestCase $this */ + $customer = grantApiAccess(User::factory()->create(['is_active' => true])); + $this->actingAs($customer); + + Livewire::test('customer.tokens') + ->set('tokenName', 'Befristet') + ->set('selectedAbilities', ['press-releases:read']) + ->set('tokenExpiresOn', now()->addDays(30)->toDateString()) + ->set('securityPassword', 'password') + ->call('createToken') + ->assertHasNoErrors() + ->assertSee(now()->addDays(30)->format('d.m.Y')); + + Livewire::test('customer.tokens') + ->set('tokenName', 'Unbefristet') + ->set('selectedAbilities', ['press-releases:read']) + ->call('createToken') + ->assertHasNoErrors() + ->assertSee('Solange gebucht'); + + $limited = $customer->tokens()->where('name', 'Befristet')->firstOrFail(); + $unlimited = $customer->tokens()->where('name', 'Unbefristet')->firstOrFail(); + + expect($limited->expires_at->toDateTimeString())->toBe(now()->addDays(30)->endOfDay()->toDateTimeString()) + ->and($unlimited->expires_at)->toBeNull(); +}); + +test('an api token expiry date must lie in the future and within five years', function (string $date) { + /** @var TestCase $this */ + $customer = grantApiAccess(User::factory()->create(['is_active' => true])); + $this->actingAs($customer); + + Livewire::test('customer.tokens') + ->set('tokenName', 'Ungültig') + ->set('selectedAbilities', ['press-releases:read']) + ->set('tokenExpiresOn', $date) + ->set('securityPassword', 'password') + ->call('createToken') + ->assertHasErrors(['tokenExpiresOn']); + + expect($customer->tokens()->count())->toBe(0); +})->with([ + 'today' => fn () => now()->toDateString(), + 'past' => fn () => now()->subDay()->toDateString(), + 'beyond five years' => fn () => now()->addYears(5)->addDay()->toDateString(), + 'not a date' => 'morgen', +]); + +test('the tokens page explains that existing tokens are paused without a booking', function () { + /** @var TestCase $this */ + $customer = User::factory()->create(['is_active' => true]); + $customer->createToken('Alte Integration', ['press-releases:read']); + $this->actingAs($customer); + + Livewire::test('customer.tokens') + ->assertSee('Ihre vorhandenen Tokens sind bis dahin gesperrt'); +}); diff --git a/tests/Feature/Images/ImagePipelineAuditTest.php b/tests/Feature/Images/ImagePipelineAuditTest.php index 26df6693..09bbf28d 100644 --- a/tests/Feature/Images/ImagePipelineAuditTest.php +++ b/tests/Feature/Images/ImagePipelineAuditTest.php @@ -40,7 +40,6 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Exceptions; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Storage; -use Laravel\Sanctum\Sanctum; use Livewire\Livewire; use Mockery\MockInterface; use Tests\Support\FailingDisk; @@ -476,7 +475,7 @@ test('the api refuses to remove an image once the status changed under the lock' }); })); - Sanctum::actingAs($owner, ['press-release-images:write']); + apiActingAs($owner, ['press-release-images:write']); $this->deleteJson('/api/v1/press-release-images/'.$image->id)->assertStatus(409); diff --git a/tests/Feature/Images/ImagePrecheckTest.php b/tests/Feature/Images/ImagePrecheckTest.php index 0d2ae956..7ba9c5ea 100644 --- a/tests/Feature/Images/ImagePrecheckTest.php +++ b/tests/Feature/Images/ImagePrecheckTest.php @@ -24,7 +24,6 @@ use Illuminate\Http\Client\Request; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Storage; -use Laravel\Sanctum\Sanctum; use Livewire\Livewire; use Tests\TestCase; @@ -475,7 +474,7 @@ test('the api answers 422 with the reason on rejection and flags queued images', /** @var TestCase $this */ $user = User::factory()->create(); $pr = PressRelease::factory()->create(['user_id' => $user->id, 'status' => PressReleaseStatus::Draft->value]); - Sanctum::actingAs($user, ['press-releases:read', 'press-release-images:write']); + apiActingAs($user, ['press-releases:read', 'press-release-images:write']); $payload = [ 'copyright' => 'Beispiel AG', diff --git a/tests/Feature/Legal/LegalAcceptanceGateTest.php b/tests/Feature/Legal/LegalAcceptanceGateTest.php index 65113b9d..21dade0f 100644 --- a/tests/Feature/Legal/LegalAcceptanceGateTest.php +++ b/tests/Feature/Legal/LegalAcceptanceGateTest.php @@ -13,7 +13,6 @@ use Database\Seeders\RolesAndPermissionsSeeder; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Exceptions; use Illuminate\Support\Facades\Mail; -use Laravel\Sanctum\Sanctum; use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException; use Livewire\Livewire; use Tests\TestCase; @@ -161,7 +160,7 @@ test('the api answers 428 with the required documents until they are accepted', /** @var TestCase $this */ config()->set('legal.acceptance_gate', true); ['owner' => $owner, 'pr' => $pr] = legalGateDraft(); - Sanctum::actingAs($owner, ['press-releases:write', 'press-releases:read']); + apiActingAs($owner, ['press-releases:write', 'press-releases:read']); $this->postJson("/api/v1/press-releases/{$pr->id}/submit", ['ai_text_declaration' => 'none']) ->assertStatus(428) diff --git a/tests/Feature/PlausibleEmailDomainTest.php b/tests/Feature/PlausibleEmailDomainTest.php index 3ad5180f..9f0eb53a 100644 --- a/tests/Feature/PlausibleEmailDomainTest.php +++ b/tests/Feature/PlausibleEmailDomainTest.php @@ -6,7 +6,6 @@ use App\Rules\PlausibleEmailDomain; use Database\Seeders\RolesAndPermissionsSeeder; use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Validator; -use Laravel\Sanctum\Sanctum; use Tests\TestCase; /** @@ -57,7 +56,7 @@ test('the legacy api refuses a company address whose domain cannot exist', funct Queue::fake(); test()->seed(RolesAndPermissionsSeeder::class); $user = User::factory()->create(['is_active' => true, 'portal' => Portal::Businessportal24]); - Sanctum::actingAs($user, ['press-releases:write']); + apiActingAs($user, ['press-releases:write']); $this->postJson('/service/users/me/companies', [ 'company' => ['name' => 'Hörwelt Basel', 'email' => 'info@hoerwelt-basel'], diff --git a/tests/Feature/PressReleaseReclassifyTest.php b/tests/Feature/PressReleaseReclassifyTest.php index 5b8f3ad0..792f1a10 100644 --- a/tests/Feature/PressReleaseReclassifyTest.php +++ b/tests/Feature/PressReleaseReclassifyTest.php @@ -10,7 +10,6 @@ use App\Models\PressRelease; use App\Models\User; use App\Services\PressRelease\PressReleaseService; use Illuminate\Support\Facades\Queue; -use Laravel\Sanctum\Sanctum; use Tests\TestCase; test('reclassifyIfClassified dispatches a non-routing job for an already classified press release', function () { @@ -84,7 +83,7 @@ test('api update of a classified press release re-classifies when the content ch 'classification' => PressReleaseClassification::Green->value, ]); - Sanctum::actingAs($user, ['press-releases:write']); + apiActingAs($user, ['press-releases:write']); $this->patchJson("/api/v1/press-releases/{$pressRelease->id}", [ 'text' => 'Komplett neuer Inhalt, der erneut geprüft werden muss.', @@ -112,7 +111,7 @@ test('api update does not re-classify when content is unchanged', function () { 'keywords' => 'alt', ]); - Sanctum::actingAs($user, ['press-releases:write']); + apiActingAs($user, ['press-releases:write']); // Nur Keywords ändern – kein Titel/Text → keine Neuklassifikation. $this->patchJson("/api/v1/press-releases/{$pressRelease->id}", [ diff --git a/tests/Pest.php b/tests/Pest.php index e0a4569a..e0260f64 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,14 +1,18 @@ create([ + 'user_id' => $user->id, + 'payment_option_id' => PaymentOption::factory()->create()->id, + 'status' => UserPaymentOptionStatus::Active->value, + 'current_period_start' => now()->subDay(), + 'current_period_end' => now()->addMonth(), + ]); + + return $user; +} + +/** + * Sanctum::actingAs() für einen Nutzer mit gebuchtem API-Zugang. + * + * @param list $abilities + */ +function apiActingAs(User $user, array $abilities = ['*']): User +{ + return Sanctum::actingAs(grantApiAccess($user), $abilities); +} + function webPageCacheKey(string $key): string { return 'web.g'.WebPageCache::generation().'.'.substr($key, strlen('web.'));