Audit: dev/sicherheit/SICHERHEITSAUDIT-2026-10-07.md, Plan und Entscheidungen: dev/sicherheit/ENTWICKLUNGSPLAN-SICHERHEIT-2026-10-07.md. - S-01 Legacy-Rechnungen fremder Kunden nur mit billing:manage (auch Kundenroute). - S-02 Kontaktzuordnung auf der Firmenanzeige verlangt companies:write, $id gesperrt. - S-03 CompanyMembershipObserver: automatische Firmenrechte werden bei jeder Adress-/Zuordnungsänderung sofort neu bewertet; memberships:resync-auto. - S-04 Passwortwechsel beendet alle anderen Sessions (PasswordChanger), Reset alle Sessions und offene Login-Links; Fortify PUT /user/password abgeschaltet. API-Tokens bleiben bestehen. - S-05 Google: email_verified Pflicht, feste Bindung über die Provider-ID; Erstverknüpfung bestehender Konten außerhalb gmail/hd nur per Bestätigungslink in derselben Browser-Session. - S-06 Frischer Identitätsnachweis (Passwort, App-Code, E-Mail-Code oder Passkey) vor E-Mail-Wechsel, erster 2FA-Einrichtung und Token-Erzeugung; Hinweis-Mails an die (bisherige) Kontoadresse. - Entscheidung 5: Passkeys (Fortify, @laravel/passkeys) für alle; Super-Admins und Admins bestätigen die Session für den Admin-Bereich per Passkey oder App-Code (EnsureStrongAdminAuthentication, Notfallschalter AUTH_REQUIRE_STRONG_ADMIN); Reset des zweiten Faktors durch Super-Admins. Neue Migration: create_passkeys_table. Suite 2.403 grün (1 übersprungen), Larastan ohne Meldung. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
336 lines
12 KiB
PHP
336 lines
12 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);
|
|
});
|