presseportale/tests/Feature/CustomerPortalTest.php
Kevin Adametz e77d9e7d09 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>
2026-10-08 11:41:24 +00:00

399 lines
14 KiB
PHP

<?php
use App\Actions\Admin\UserImpersonation;
use App\Enums\InvoiceStatus;
use App\Enums\UserPaymentOptionStatus;
use App\Models\Invoice;
use App\Models\LegacyInvoice;
use App\Models\User;
use App\Models\UserPaymentOption;
use Database\Seeders\RolesAndPermissionsSeeder;
use Livewire\Livewire;
use Tests\TestCase;
test('customer can create and revoke api tokens with selected abilities', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
UserPaymentOption::factory()->create([
'user_id' => $customer->id,
'status' => UserPaymentOptionStatus::Active->value,
'current_period_start' => now()->subDay(),
'current_period_end' => now()->addMonth(),
]);
$this->actingAs($customer);
Livewire::test('customer.tokens')
->assertSee('API-Tokens')
->set('tokenName', 'Website Integration')
->set('selectedAbilities', ['press-releases:read', 'companies:read'])
->set('securityPassword', 'password')
->call('createToken')
->assertHasNoErrors()
->assertSee('Token wurde erstellt');
$token = $customer->tokens()->firstOrFail();
expect($token->name)->toBe('Website Integration');
expect($token->abilities)->toBe(['press-releases:read', 'companies:read']);
Livewire::test('customer.tokens')
->call('revokeToken', $token->id)
->assertSee('Token wurde widerrufen.');
expect($customer->tokens()->count())->toBe(0);
});
test('the tokens page links the api docs page and the named spec download', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
$this->actingAs($customer);
// Doku-Button öffnet die API-Seite; der Spec-Download ist ein eigener,
// klar benannter Button (kein Sofort-Download hinter „Dokumentation“).
Livewire::test('customer.tokens')
->assertSee('API-Dokumentation')
->assertSee(route('api', ['edition' => 'de']))
->assertSee('OpenAPI-Spezifikation (YAML)')
->assertSee(route('docs.api.v1'));
});
test('customer cannot create api token without active payment access', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
$this->actingAs($customer);
Livewire::test('customer.tokens')
->set('tokenName', 'Website Integration')
->set('selectedAbilities', ['press-releases:read'])
->call('createToken')
->assertSee('API-Tokens werden erst freigeschaltet');
expect($customer->tokens()->count())->toBe(0);
});
test('api tokens cannot be created or revoked during impersonation', function () {
/** @var TestCase $this */
$admin = User::factory()->create();
$customer = User::factory()->create(['is_active' => true]);
UserPaymentOption::factory()->create([
'user_id' => $customer->id,
'status' => UserPaymentOptionStatus::Active->value,
'current_period_start' => now()->subDay(),
'current_period_end' => now()->addMonth(),
]);
$token = $customer->createToken('Existing token', ['press-releases:read'])->accessToken;
$this->actingAs($customer)
->withSession([UserImpersonation::SessionKey => $admin->id]);
Livewire::test('customer.tokens')
->assertSee('Während der Benutzer-Impersonation können keine API-Tokens erstellt oder widerrufen werden.')
->set('tokenName', 'Impersonated token')
->set('selectedAbilities', ['press-releases:read'])
->call('createToken')
->assertForbidden();
Livewire::test('customer.tokens')
->call('revokeToken', $token->id)
->assertForbidden();
expect($customer->tokens()->pluck('id')->all())->toBe([$token->id]);
});
test('customer sees only own legacy invoices', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
$otherUser = User::factory()->create(['is_active' => true]);
LegacyInvoice::query()->create([
'legacy_portal' => 'presseecho',
'legacy_id' => 1001,
'user_id' => $customer->id,
'legacy_user_id' => 501,
'number' => 'RE-CUSTOMER-001',
'amount_cents' => 9900,
'total_cents' => 9900,
'status' => 'paid',
'invoice_date' => now()->subMonth(),
'paid_at' => now()->subMonth()->addDays(2),
'imported_at' => now(),
]);
LegacyInvoice::query()->create([
'legacy_portal' => 'businessportal24',
'legacy_id' => 1002,
'user_id' => $otherUser->id,
'legacy_user_id' => 502,
'number' => 'RE-OTHER-001',
'amount_cents' => 14900,
'total_cents' => 14900,
'status' => 'paid',
'invoice_date' => now()->subMonth(),
'paid_at' => now()->subMonth()->addDays(2),
'imported_at' => now(),
]);
// Nie bezahlte Alt-Rechnung (hold): erledigt, erscheint dem Kunden nicht.
LegacyInvoice::query()->create([
'legacy_portal' => 'presseecho',
'legacy_id' => 1003,
'user_id' => $customer->id,
'legacy_user_id' => 501,
'number' => 'RE-CUSTOMER-HOLD',
'amount_cents' => 9900,
'total_cents' => 9900,
'status' => 'hold',
'invoice_date' => now()->subYears(2),
'imported_at' => now(),
]);
$this->actingAs($customer);
Livewire::test('customer.invoices')
->assertSee('RE-CUSTOMER-001')
->assertDontSee('RE-OTHER-001')
->assertDontSee('RE-CUSTOMER-HOLD')
->assertSee('99,00 €');
});
test('the legacy invoice archive is hidden for customers without archived invoices', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
// Archiv eines anderen Users darf die Anzeige nicht auslösen.
LegacyInvoice::query()->create([
'legacy_portal' => 'presseecho',
'legacy_id' => 1003,
'user_id' => User::factory()->create(['is_active' => true])->id,
'legacy_user_id' => 503,
'number' => 'RE-FREMD-001',
'amount_cents' => 9900,
'total_cents' => 9900,
'status' => 'paid',
'invoice_date' => now()->subMonth(),
'paid_at' => now()->subMonth(),
'imported_at' => now(),
]);
$this->actingAs($customer);
Livewire::test('customer.invoices')
->assertSee('Aktuelle Rechnungen')
->assertDontSee('Rechnungsarchiv (Altsystem)')
->assertDontSee('Filter & Suche');
});
test('current STR and MAN invoices are listed with a stripe link where available', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
$strInvoice = Invoice::factory()->create([
'user_id' => $customer->id,
'user_payment_id' => null,
'number' => 'STR-00042',
'status' => InvoiceStatus::Paid->value,
'amount_cents' => 2900,
'tax_cents' => 551,
'total_cents' => 3451,
'stripe_invoice_id' => 'in_test_42',
]);
Invoice::factory()->create([
'user_id' => $customer->id,
'user_payment_id' => null,
'number' => 'MAN-00007',
'status' => InvoiceStatus::Paid->value,
'stripe_invoice_id' => null,
]);
Invoice::factory()->create([
'user_payment_id' => null,
'number' => 'STR-99999',
'stripe_invoice_id' => 'in_test_other',
]);
$this->actingAs($customer);
Livewire::test('customer.invoices')
->assertSee('Aktuelle Rechnungen')
->assertSee('STR-00042')
->assertSee('MAN-00007')
->assertDontSee('STR-99999')
->assertSee('PDF öffnen')
->assertSee(route('me.invoices.stripe-pdf', $strInvoice))
->assertSee('Beleg bei Stripe')
->assertSee(route('me.invoices.stripe', $strInvoice));
});
test('the stripe invoice redirect rejects foreign invoices and ones without stripe id', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$customer = User::factory()->create(['is_active' => true]);
$customer->assignRole('customer');
$intruder = User::factory()->create(['is_active' => true]);
$intruder->assignRole('customer');
$strInvoice = Invoice::factory()->create([
'user_id' => $customer->id,
'user_payment_id' => null,
'stripe_invoice_id' => 'in_test_43',
]);
$manInvoice = Invoice::factory()->create([
'user_id' => $customer->id,
'user_payment_id' => null,
'stripe_invoice_id' => null,
]);
$this->actingAs($intruder)
->get(route('me.invoices.stripe', $strInvoice))
->assertNotFound();
$this->actingAs($customer)
->get(route('me.invoices.stripe', $manInvoice))
->assertNotFound();
});
test('customer can open generated legacy invoice pdf inline from archive data', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$customer = User::factory()->create(['is_active' => true]);
$customer->assignRole('customer');
$invoice = LegacyInvoice::query()->create([
'legacy_portal' => 'presseecho',
'legacy_id' => 1003,
'user_id' => $customer->id,
'legacy_user_id' => 503,
'number' => 'RE-CUSTOMER-002',
'amount_cents' => 9900,
'total_cents' => 9900,
'status' => 'paid',
'invoice_date' => now()->subMonth(),
'due_date' => now()->subMonth()->addDays(14),
'paid_at' => now()->subMonth()->addDays(2),
'payment_method' => 'SPK_Berlin',
'raw_snapshot' => [
'number' => 'RE-CUSTOMER-002',
'service_period_begin_date' => now()->subYear()->toDateString(),
'service_period_end_date' => now()->toDateString(),
],
'pdf_payload' => [
'billing_address' => [
'name' => 'Customer GmbH',
'address' => 'Kundenstrasse 1',
'postal_code' => '10115',
'city' => 'Berlin',
'country_name' => 'Deutschland',
],
],
'imported_at' => now(),
]);
$this->actingAs($customer);
Livewire::test('customer.invoices')
->assertSee('Rechnungsarchiv (Altsystem)')
->assertSee('Rechnungsadresse im Profil pflegen')
->assertSee('Öffnen');
$this->get(route('me.invoices.pdf', $invoice))
->assertSuccessful()
->assertHeader('content-type', 'application/pdf')
->assertHeader('content-disposition', 'inline; filename="Presseecho-RNr-RE-CUSTOMER-002.pdf"');
expect($invoice->refresh()->pdf_generated_at)->not->toBeNull();
});
test('creating an api token requires a fresh identity proof (Sicherheitsaudit S-06)', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);
UserPaymentOption::factory()->create([
'user_id' => $customer->id,
'status' => UserPaymentOptionStatus::Active->value,
'current_period_start' => now()->subDay(),
'current_period_end' => now()->addMonth(),
]);
$this->actingAs($customer);
Livewire::test('customer.tokens')
->set('tokenName', 'Ohne Nachweis')
->set('selectedAbilities', ['press-releases:read'])
->call('createToken')
->assertHasErrors(['securityPassword']);
Livewire::test('customer.tokens')
->set('tokenName', 'Falsches Passwort')
->set('selectedAbilities', ['press-releases:read'])
->set('securityPassword', 'falsch')
->call('createToken')
->assertHasErrors(['securityPassword']);
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');
});