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:
Kevin Adametz 2026-10-08 11:41:24 +00:00
parent 3def447f51
commit e77d9e7d09
24 changed files with 319 additions and 66 deletions

View file

@ -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;
}

View file

@ -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');

View file

@ -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',

View file

@ -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',

View file

@ -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,

View file

@ -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'),

View file

@ -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;

View file

@ -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).

View file

@ -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();
});

View file

@ -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'],

View file

@ -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');
});

View file

@ -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);

View file

@ -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',

View file

@ -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)

View file

@ -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'],

View file

@ -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}", [