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
7fa310fa76
commit
6d85d2982a
24 changed files with 319 additions and 66 deletions
|
|
@ -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