feat: API-Zugang endet mit der Buchung, optionales Ablaufdatum für API-Tokens
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>
This commit is contained in:
parent
3def447f51
commit
e77d9e7d09
24 changed files with 319 additions and 66 deletions
38
app/Http/Middleware/EnsureApiAccessIsBooked.php
Normal file
38
app/Http/Middleware/EnsureApiAccessIsBooked.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\Api\ApiAccessEligibilityService;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* API-Zugang nur mit laufender Buchung (08.10.2026): aktives Abo,
|
||||
* aktive Zahlungsoption oder Bestandsschutz — dieselbe Regel wie beim
|
||||
* Erzeugen eines Tokens (ApiAccessEligibilityService), jetzt bei jedem
|
||||
* Aufruf. Endet die Buchung, sperren die Tokens; sie bleiben erhalten und
|
||||
* funktionieren wieder, sobald erneut gebucht ist. Gilt für API v1 und die
|
||||
* Legacy-Aliasse unter /service.
|
||||
*/
|
||||
class EnsureApiAccessIsBooked
|
||||
{
|
||||
public function __construct(private readonly ApiAccessEligibilityService $eligibility) {}
|
||||
|
||||
/**
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string> */
|
||||
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
|
|||
<div class="px-4 py-3 rounded-[5px] border text-[12.5px] flex items-start gap-3
|
||||
bg-[color:var(--color-warn-soft)] border-[color:var(--color-warn)]/30 text-[color:var(--color-ink-2)]">
|
||||
<flux:icon.lock-closed class="size-[16px] flex-shrink-0 mt-0.5 text-[color:var(--color-accent-deep)]" />
|
||||
<div class="flex-1">{{ $eligibilityMessage ?? $apiTokenDenialReason }}</div>
|
||||
<div class="flex-1">
|
||||
{{ $eligibilityMessage ?? $apiTokenDenialReason }}
|
||||
@if ($tokens->isNotEmpty() && ! $hasApiAccess)
|
||||
<span class="block mt-1">{{ __('Ihre vorhandenen Tokens sind bis dahin gesperrt. Sie bleiben gespeichert und funktionieren wieder, sobald eine Buchung aktiv ist.') }}</span>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
|
|
@ -189,6 +207,13 @@ new #[Layout('components.layouts.app'), Title('API-Tokens')] class extends Compo
|
|||
<flux:error name="tokenName" />
|
||||
</flux:field>
|
||||
|
||||
<flux:field>
|
||||
<flux:label>{{ __('Gültig bis (optional)') }}</flux:label>
|
||||
<flux:input type="date" wire:model="tokenExpiresOn" min="{{ now()->addDay()->toDateString() }}" max="{{ now()->addYears(5)->toDateString() }}" class="max-w-[220px]" />
|
||||
<flux:description>{{ __('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.') }}</flux:description>
|
||||
<flux:error name="tokenExpiresOn" />
|
||||
</flux:field>
|
||||
|
||||
<div>
|
||||
<flux:label>{{ __('Berechtigungen') }}</flux:label>
|
||||
<div class="mt-3 grid gap-3 md:grid-cols-2">
|
||||
|
|
@ -228,6 +253,7 @@ new #[Layout('components.layouts.app'), Title('API-Tokens')] class extends Compo
|
|||
<flux:table.column>{{ __('Berechtigungen') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Erstellt') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Zuletzt genutzt') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Gültig bis') }}</flux:table.column>
|
||||
<flux:table.column>{{ __('Aktionen') }}</flux:table.column>
|
||||
</flux:table.columns>
|
||||
|
||||
|
|
@ -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') }}
|
||||
</span>
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
@if ($token->expires_at?->isPast())
|
||||
<span class="badge warn">{{ __('Abgelaufen am :date', ['date' => $token->expires_at->format('d.m.Y')]) }}</span>
|
||||
@elseif ($token->expires_at)
|
||||
<span class="text-[12px] text-[color:var(--color-ink-3)]">{{ $token->expires_at->format('d.m.Y') }}</span>
|
||||
@else
|
||||
<span class="text-[12px] text-[color:var(--color-ink-3)]">{{ __('Solange gebucht') }}</span>
|
||||
@endif
|
||||
</flux:table.cell>
|
||||
<flux:table.cell>
|
||||
<flux:button
|
||||
size="sm"
|
||||
|
|
@ -268,7 +303,7 @@ new #[Layout('components.layouts.app'), Title('API-Tokens')] class extends Compo
|
|||
</flux:table.row>
|
||||
@empty
|
||||
<flux:table.row>
|
||||
<flux:table.cell colspan="5">
|
||||
<flux:table.cell colspan="6">
|
||||
<div class="flex flex-col items-center justify-center px-4 py-10 text-center">
|
||||
<div class="w-14 h-14 rounded-[6px] flex items-center justify-center mb-3
|
||||
bg-[color:var(--color-hub-soft)] border border-[color:var(--color-hub-soft-2)] text-[color:var(--color-hub)]">
|
||||
|
|
|
|||
|
|
@ -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]+'])
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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]+'])
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
<?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 = User::factory()->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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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}", [
|
||||
|
|
|
|||
|
|
@ -1,14 +1,18 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\UserPaymentOptionStatus;
|
||||
use App\Models\Category;
|
||||
use App\Models\Company;
|
||||
use App\Models\Contact;
|
||||
use App\Models\PaymentOption;
|
||||
use App\Models\Plan;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\User;
|
||||
use App\Models\UserPaymentOption;
|
||||
use App\Services\CurrentPortalContext;
|
||||
use App\Support\WebPageCache;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Tests\TestCase;
|
||||
|
||||
/*
|
||||
|
|
@ -86,6 +90,34 @@ function subscribeUserToPlan(User $user, Plan $plan, string $interval = 'monthly
|
|||
* `web.`-Seiten-Caches auf einmal. Die Tests prüfen weiterhin die fachliche
|
||||
* Key-Struktur, nur eben generationsunabhängig.
|
||||
*/
|
||||
/**
|
||||
* API-Zugang setzt eine laufende Buchung voraus (EnsureApiAccessIsBooked,
|
||||
* 08.10.2026). Für API-Tests, die nicht die Buchung selbst prüfen: aktive
|
||||
* Zahlungsoption anlegen.
|
||||
*/
|
||||
function grantApiAccess(User $user): User
|
||||
{
|
||||
UserPaymentOption::factory()->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<string> $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.'));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue