diff --git a/app/Livewire/Admin/ConfirmReleaseBlock.php b/app/Livewire/Admin/ConfirmReleaseBlock.php new file mode 100644 index 0000000..569a394 --- /dev/null +++ b/app/Livewire/Admin/ConfirmReleaseBlock.php @@ -0,0 +1,51 @@ +where('uuid', $uuid)->first(); + + abort_if($block === null, 404); + + $this->authorize($block->instance_id !== null ? 'instances.manage' : 'hosts.manage'); + + $this->uuid = $uuid; + $this->ip = $block->ip; + } + + public function confirm(): void + { + $this->dispatch('security-block-release-confirmed', uuid: $this->uuid); + $this->closeModal(); + } + + public function render() + { + return view('livewire.admin.confirm-release-block'); + } +} diff --git a/app/Livewire/Admin/CustomerDetail.php b/app/Livewire/Admin/CustomerDetail.php index a43cd40..ea52c60 100644 --- a/app/Livewire/Admin/CustomerDetail.php +++ b/app/Livewire/Admin/CustomerDetail.php @@ -9,6 +9,7 @@ use App\Models\Instance; use App\Models\Invoice; use App\Models\MailTemplate; use App\Models\Order; +use App\Models\SecurityBlock; use App\Models\SentMail; use App\Models\Subscription; use App\Models\SupportRequest; @@ -16,6 +17,7 @@ use App\Services\Mail\MailTemplateRenderer; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Mail; use Livewire\Attributes\Layout; +use Livewire\Attributes\On; use Livewire\Attributes\Url; use Livewire\Attributes\Validate; use Livewire\Component; @@ -150,6 +152,31 @@ class CustomerDetail extends Component $this->body = ''; } + /** + * ConfirmReleaseBlock (Konsole) dispatches this back (R23) — see that + * class. Es mutiert nichts; die eigentliche Freigabe UND die Pruefung, + * dass die Sperre wirklich an EINER Instanz DIESES Kunden haengt, stehen + * hier — ein Modal ist ohne die Route-Middleware dieser Seite erreichbar + * (R20). + */ + #[On('security-block-release-confirmed')] + public function onSecurityBlockReleaseConfirmed(string $uuid): void + { + $this->authorize('instances.manage'); + + $block = SecurityBlock::query() + ->where('uuid', $uuid) + ->whereHas('instance', fn ($q) => $q->where('customer_id', $this->customer->id)) + ->first(); + + if ($block === null) { + return; + } + + $block->release(auth('operator')->user()); + $this->dispatch('notify', message: __('admin.security_block.released')); + } + /** * Send it, record it, and close the request it answers. * @@ -189,13 +216,21 @@ class CustomerDetail extends Component { $this->authorize('customers.manage'); + $instance = Instance::query() + ->where('customer_id', $this->customer->id) + ->latest('id') + ->first(); + return view('livewire.admin.customer-detail', [ 'tabs' => self::TABS, 'subscription' => $this->subscription(), - 'instance' => Instance::query() - ->where('customer_id', $this->customer->id) - ->latest('id') - ->first(), + 'instance' => $instance, + // Sicherheitssperren DIESER Instanz — derselbe Abschnitt (Zeilen + // + Knopf) wie auf der Host-Detailseite, hinter instances.manage + // statt hosts.manage. + 'securityBlocks' => $instance + ? SecurityBlock::query()->where('instance_id', $instance->id)->orderByDesc('blocked_at')->get() + : collect(), 'orders' => Order::query() ->where('customer_id', $this->customer->id) ->latest('id') diff --git a/app/Livewire/Admin/HostDetail.php b/app/Livewire/Admin/HostDetail.php index a8fc908..88cc3d7 100644 --- a/app/Livewire/Admin/HostDetail.php +++ b/app/Livewire/Admin/HostDetail.php @@ -5,6 +5,7 @@ namespace App\Livewire\Admin; use App\Models\Customer; use App\Models\Host; use App\Models\ProvisioningRun; +use App\Models\SecurityBlock; use App\Provisioning\Jobs\AdvanceRunJob; use App\Provisioning\Jobs\CollectHostLoad; use App\Services\Proxmox\HostLoadSeries; @@ -108,6 +109,27 @@ class HostDetail extends Component $this->releaseReservation(); } + /** + * ConfirmReleaseBlock (Konsole) dispatches this back (R23) — see that + * class. Es mutiert nichts; die eigentliche Freigabe UND die Pruefung, + * dass die Sperre wirklich an DIESEM Host haengt, stehen hier — ein + * Modal ist ohne die Route-Middleware dieser Seite erreichbar (R20). + */ + #[On('security-block-release-confirmed')] + public function onSecurityBlockReleaseConfirmed(string $uuid): void + { + $this->authorize('hosts.manage'); + + $block = SecurityBlock::query()->where('uuid', $uuid)->where('host_id', $this->host->id)->first(); + + if ($block === null) { + return; + } + + $block->release(auth('operator')->user()); + $this->dispatch('notify', message: __('admin.security_block.released')); + } + public function retry(): void { $this->authorize('hosts.manage'); @@ -202,6 +224,10 @@ class HostDetail extends Component ], 'version' => PveVersion::parse($this->host->pve_version), 'fqdn' => HostName::fqdn($this->host->name), + 'securityBlocks' => SecurityBlock::query() + ->where('host_id', $this->host->id) + ->orderByDesc('blocked_at') + ->get(), // Nur gebraucht, solange der Host noch niemandem gehört — aber // billig genug (eine Namensliste), um sie immer mitzugeben statt // eine zweite Bedingung ums Laden zu ziehen. diff --git a/app/Livewire/Admin/Overview.php b/app/Livewire/Admin/Overview.php index b3dd5bb..6fa29ec 100644 --- a/app/Livewire/Admin/Overview.php +++ b/app/Livewire/Admin/Overview.php @@ -9,6 +9,7 @@ use App\Models\Host; use App\Models\Instance; use App\Models\MonitoringTarget; use App\Models\ProvisioningRun; +use App\Models\SecurityBlock; use App\Models\Subscription; use App\Services\Billing\PlanCatalogue; use App\Services\Provisioning\HostCapacity; @@ -355,6 +356,22 @@ class Overview extends Component ]; } + // An address locked out after repeated failed sign-ins — host SSH or + // an instance's own login, see SecurityBlock. Task 6 gave the two + // kinds a section each (host detail, a customer's instance) but no + // single list of both together, so this links to whichever list an + // operator would actually find the flagged subject on: the host list + // if a host is currently blocked (the more urgent case — somebody is + // hammering a hypervisor's SSH), the instance list otherwise. + $activeBlocks = SecurityBlock::active()->count(); + if ($activeBlocks > 0) { + $notices[] = [ + 'level' => 'warning', + 'text' => __('admin.notice.security_blocks', ['n' => $activeBlocks]), + 'route' => SecurityBlock::active()->whereNotNull('host_id')->exists() ? 'admin.hosts' : 'admin.instances', + ]; + } + // Capacity, before an order finds out. // // The platform books thick: a package reserves its whole `disk_gb` on diff --git a/app/Livewire/ConfirmReleaseBlock.php b/app/Livewire/ConfirmReleaseBlock.php new file mode 100644 index 0000000..c94a1ee --- /dev/null +++ b/app/Livewire/ConfirmReleaseBlock.php @@ -0,0 +1,52 @@ +customer(); + + $block = SecurityBlock::query() + ->where('uuid', $uuid) + ->whereHas('instance', fn ($q) => $q->where('customer_id', $customer?->id)) + ->first(); + + abort_if($block === null, 404); + + $this->uuid = $uuid; + $this->ip = $block->ip; + } + + public function confirm(): void + { + $this->dispatch('block-release-confirmed', uuid: $this->uuid); + $this->closeModal(); + } + + public function render() + { + return view('livewire.confirm-release-block'); + } +} diff --git a/app/Livewire/Security.php b/app/Livewire/Security.php new file mode 100644 index 0000000..a0b6dfd --- /dev/null +++ b/app/Livewire/Security.php @@ -0,0 +1,70 @@ +customer(); + + $block = SecurityBlock::query() + ->where('uuid', $uuid) + ->whereHas('instance', fn ($q) => $q->where('customer_id', $customer?->id)) + ->first(); + + abort_if($block === null, 403); + + $block->release(auth()->user()); + + $this->dispatch('notify', message: __('security.blocks_released')); + } + + public function render() + { + $customer = $this->customer(); + + $blocks = $customer + ? SecurityBlock::query() + ->whereHas('instance', fn ($q) => $q->where('customer_id', $customer->id)) + ->orderByDesc('blocked_at') + ->get() + : collect(); + + // Aktiv/Verlauf wird hier gesplittet, nicht per zweiter Abfrage: + // scopeActive() prueft dieselben zwei Felder, die die Menge schon in + // der Hand haelt. + return view('livewire.security', [ + 'active' => $blocks->filter( + fn (SecurityBlock $b) => $b->released_at === null && $b->expires_at->isFuture() + )->values(), + 'history' => $blocks->filter( + fn (SecurityBlock $b) => $b->released_at !== null || $b->expires_at->isPast() + )->values(), + ]); + } +} diff --git a/app/Mail/CloudResumedMail.php b/app/Mail/CloudResumedMail.php index 57537a3..66ffd7c 100644 --- a/app/Mail/CloudResumedMail.php +++ b/app/Mail/CloudResumedMail.php @@ -2,6 +2,7 @@ namespace App\Mail; +use App\Mail\Concerns\RidesALane; use App\Mail\Concerns\SendsFromMailbox; use App\Services\Mail\MailPurpose; use Illuminate\Bus\Queueable; @@ -19,7 +20,7 @@ use Illuminate\Queue\SerializesModels; */ class CloudResumedMail extends Mailable implements ShouldQueue { - use Queueable, SendsFromMailbox, SerializesModels; + use Queueable, RidesALane, SendsFromMailbox, SerializesModels; public function __construct( public string $name, diff --git a/app/Mail/CloudSuspendedMail.php b/app/Mail/CloudSuspendedMail.php index 5849d8d..f2d3b01 100644 --- a/app/Mail/CloudSuspendedMail.php +++ b/app/Mail/CloudSuspendedMail.php @@ -2,6 +2,7 @@ namespace App\Mail; +use App\Mail\Concerns\RidesALane; use App\Mail\Concerns\SendsFromMailbox; use App\Services\Mail\MailPurpose; use Illuminate\Bus\Queueable; @@ -19,7 +20,7 @@ use Illuminate\Queue\SerializesModels; */ class CloudSuspendedMail extends Mailable implements ShouldQueue { - use Queueable, SendsFromMailbox, SerializesModels; + use Queueable, RidesALane, SendsFromMailbox, SerializesModels; public function __construct( public string $name, diff --git a/app/Mail/Concerns/RidesALane.php b/app/Mail/Concerns/RidesALane.php new file mode 100644 index 0000000..282d05c --- /dev/null +++ b/app/Mail/Concerns/RidesALane.php @@ -0,0 +1,27 @@ +onQueue(MailLane::for(static::class)); + + return parent::queue($queue); + } +} diff --git a/app/Mail/ContactRequestMail.php b/app/Mail/ContactRequestMail.php index af17457..0d3c081 100644 --- a/app/Mail/ContactRequestMail.php +++ b/app/Mail/ContactRequestMail.php @@ -2,6 +2,7 @@ namespace App\Mail; +use App\Mail\Concerns\RidesALane; use App\Mail\Concerns\SendsFromMailbox; use App\Services\Mail\MailPurpose; use Illuminate\Bus\Queueable; @@ -28,7 +29,7 @@ use Illuminate\Queue\SerializesModels; */ class ContactRequestMail extends Mailable implements ShouldQueue { - use Queueable, SendsFromMailbox, SerializesModels; + use Queueable, RidesALane, SendsFromMailbox, SerializesModels; /** * @param array{company: ?string, name: string, email: string, phone: ?string, message: string, topic: ?string} $enquiry diff --git a/app/Mail/DormantAccountWarningMail.php b/app/Mail/DormantAccountWarningMail.php index 537a150..efdc418 100644 --- a/app/Mail/DormantAccountWarningMail.php +++ b/app/Mail/DormantAccountWarningMail.php @@ -2,6 +2,7 @@ namespace App\Mail; +use App\Mail\Concerns\RidesALane; use App\Mail\Concerns\SendsFromMailbox; use App\Models\User; use App\Services\Mail\MailPurpose; @@ -25,7 +26,7 @@ use Illuminate\Queue\SerializesModels; */ class DormantAccountWarningMail extends Mailable implements ShouldQueue { - use Queueable, SendsFromMailbox, SerializesModels; + use Queueable, RidesALane, SendsFromMailbox, SerializesModels; public function __construct(public User $user, public int $days) { diff --git a/app/Mail/DunningNoticeMail.php b/app/Mail/DunningNoticeMail.php index bdcfe53..037de2b 100644 --- a/app/Mail/DunningNoticeMail.php +++ b/app/Mail/DunningNoticeMail.php @@ -2,6 +2,7 @@ namespace App\Mail; +use App\Mail\Concerns\RidesALane; use App\Mail\Concerns\SendsFromMailbox; use App\Services\Mail\MailPurpose; use Illuminate\Bus\Queueable; @@ -26,7 +27,7 @@ use Illuminate\Queue\SerializesModels; */ class DunningNoticeMail extends Mailable implements ShouldQueue { - use Queueable, SendsFromMailbox, SerializesModels; + use Queueable, RidesALane, SendsFromMailbox, SerializesModels; /** * @param int $level 0 = Hinweis, 1–3 = Mahnstufe diff --git a/app/Mail/InvoiceMail.php b/app/Mail/InvoiceMail.php index fbeba21..a897639 100644 --- a/app/Mail/InvoiceMail.php +++ b/app/Mail/InvoiceMail.php @@ -2,6 +2,7 @@ namespace App\Mail; +use App\Mail\Concerns\RidesALane; use App\Mail\Concerns\SendsFromMailbox; use App\Models\Invoice; use App\Services\Billing\InvoiceMath; @@ -27,7 +28,7 @@ use Illuminate\Queue\SerializesModels; */ class InvoiceMail extends Mailable implements ShouldQueue { - use Queueable, SendsFromMailbox, SerializesModels; + use Queueable, RidesALane, SendsFromMailbox, SerializesModels; public function __construct(public Invoice $invoice, public string $name) { diff --git a/app/Mail/MaintenanceAnnouncementMail.php b/app/Mail/MaintenanceAnnouncementMail.php index 8dbc7bb..4002564 100644 --- a/app/Mail/MaintenanceAnnouncementMail.php +++ b/app/Mail/MaintenanceAnnouncementMail.php @@ -2,6 +2,7 @@ namespace App\Mail; +use App\Mail\Concerns\RidesALane; use App\Mail\Concerns\SendsFromMailbox; use App\Models\Customer; use App\Models\MaintenanceWindow; @@ -16,7 +17,7 @@ use Illuminate\Queue\SerializesModels; class MaintenanceAnnouncementMail extends Mailable implements ShouldQueue { - use Queueable, SendsFromMailbox, SerializesModels; + use Queueable, RidesALane, SendsFromMailbox, SerializesModels; /** The ledger row this mail confirms once actually delivered. */ public ?int $notificationId = null; diff --git a/app/Mail/MaintenanceCancelledMail.php b/app/Mail/MaintenanceCancelledMail.php index 05ac6ff..b83887e 100644 --- a/app/Mail/MaintenanceCancelledMail.php +++ b/app/Mail/MaintenanceCancelledMail.php @@ -2,6 +2,7 @@ namespace App\Mail; +use App\Mail\Concerns\RidesALane; use App\Mail\Concerns\SendsFromMailbox; use App\Models\Customer; use App\Models\MaintenanceWindow; @@ -16,7 +17,7 @@ use Illuminate\Queue\SerializesModels; class MaintenanceCancelledMail extends Mailable implements ShouldQueue { - use Queueable, SendsFromMailbox, SerializesModels; + use Queueable, RidesALane, SendsFromMailbox, SerializesModels; /** The ledger row this mail confirms once actually delivered. */ public ?int $notificationId = null; diff --git a/app/Mail/NewDeviceSignInMail.php b/app/Mail/NewDeviceSignInMail.php index 2fb26e7..7634ec1 100644 --- a/app/Mail/NewDeviceSignInMail.php +++ b/app/Mail/NewDeviceSignInMail.php @@ -2,6 +2,7 @@ namespace App\Mail; +use App\Mail\Concerns\RidesALane; use App\Mail\Concerns\SendsFromMailbox; use App\Models\UserDevice; use App\Services\Mail\MailPurpose; @@ -24,7 +25,7 @@ use Illuminate\Queue\SerializesModels; */ class NewDeviceSignInMail extends Mailable implements ShouldQueue { - use Queueable, SendsFromMailbox, SerializesModels; + use Queueable, RidesALane, SendsFromMailbox, SerializesModels; public function __construct( public string $name, diff --git a/app/Mail/OperatorMessageMail.php b/app/Mail/OperatorMessageMail.php index d8df2f1..7016784 100644 --- a/app/Mail/OperatorMessageMail.php +++ b/app/Mail/OperatorMessageMail.php @@ -2,6 +2,7 @@ namespace App\Mail; +use App\Mail\Concerns\RidesALane; use App\Mail\Concerns\SendsFromMailbox; use App\Models\Customer; use App\Services\Mail\MailPurpose; @@ -26,7 +27,7 @@ use Illuminate\Queue\SerializesModels; */ class OperatorMessageMail extends Mailable implements ShouldQueue { - use Queueable, SendsFromMailbox, SerializesModels; + use Queueable, RidesALane, SendsFromMailbox, SerializesModels; public function __construct( public Customer $customer, diff --git a/app/Mail/OrderConfirmationMail.php b/app/Mail/OrderConfirmationMail.php index 1a6882e..0e67669 100644 --- a/app/Mail/OrderConfirmationMail.php +++ b/app/Mail/OrderConfirmationMail.php @@ -2,6 +2,7 @@ namespace App\Mail; +use App\Mail\Concerns\RidesALane; use App\Mail\Concerns\SendsFromMailbox; use App\Models\Datacenter; use App\Models\Order; @@ -29,7 +30,7 @@ use Illuminate\Queue\SerializesModels; */ class OrderConfirmationMail extends Mailable implements ShouldQueue { - use Queueable, SendsFromMailbox, SerializesModels; + use Queueable, RidesALane, SendsFromMailbox, SerializesModels; public function __construct(public Order $order, public string $name) { diff --git a/app/Mail/ResetPasswordMail.php b/app/Mail/ResetPasswordMail.php index d4137b8..37828cd 100644 --- a/app/Mail/ResetPasswordMail.php +++ b/app/Mail/ResetPasswordMail.php @@ -2,6 +2,7 @@ namespace App\Mail; +use App\Mail\Concerns\RidesALane; use App\Mail\Concerns\SendsFromMailbox; use App\Models\User; use App\Services\Mail\MailPurpose; @@ -24,7 +25,7 @@ use Illuminate\Queue\SerializesModels; */ class ResetPasswordMail extends Mailable implements ShouldQueue { - use Queueable, SendsFromMailbox, SerializesModels; + use Queueable, RidesALane, SendsFromMailbox, SerializesModels; public function __construct(public User $user, public string $url, public int $minutes) { diff --git a/app/Mail/SecurityBlockMail.php b/app/Mail/SecurityBlockMail.php index 6a2b3d7..72c38a5 100644 --- a/app/Mail/SecurityBlockMail.php +++ b/app/Mail/SecurityBlockMail.php @@ -2,6 +2,7 @@ namespace App\Mail; +use App\Mail\Concerns\RidesALane; use App\Mail\Concerns\SendsFromMailbox; use App\Models\SecurityBlock; use App\Services\Mail\MailPurpose; @@ -30,7 +31,7 @@ use Illuminate\Queue\SerializesModels; */ class SecurityBlockMail extends Mailable implements ShouldQueue { - use Queueable, SendsFromMailbox, SerializesModels; + use Queueable, RidesALane, SendsFromMailbox, SerializesModels; public function __construct(public SecurityBlock $block) { diff --git a/app/Mail/VerifyEmailMail.php b/app/Mail/VerifyEmailMail.php index f623ded..af0d525 100644 --- a/app/Mail/VerifyEmailMail.php +++ b/app/Mail/VerifyEmailMail.php @@ -2,6 +2,7 @@ namespace App\Mail; +use App\Mail\Concerns\RidesALane; use App\Mail\Concerns\SendsFromMailbox; use App\Models\User; use App\Services\Mail\MailPurpose; @@ -26,7 +27,7 @@ use Illuminate\Support\Facades\URL; */ class VerifyEmailMail extends Mailable implements ShouldQueue { - use Queueable, SendsFromMailbox, SerializesModels; + use Queueable, RidesALane, SendsFromMailbox, SerializesModels; public function __construct(public User $user) { diff --git a/app/Support/Navigation.php b/app/Support/Navigation.php index f6b61ea..9bae82a 100644 --- a/app/Support/Navigation.php +++ b/app/Support/Navigation.php @@ -27,6 +27,10 @@ final class Navigation ['domain', 'globe', 'domain', 'use-custom-domain'], ['users', 'users', 'users', null], ['backups', 'database', 'backups', null], + // Route heisst 'portal.security' statt bloss 'security' — der + // Pfad '/security' gehoert schon der oeffentlichen + // Aufklaerungsseite (routes/web.php), siehe deren Kommentar. + ['portal.security', 'shield', 'security', null], ]], ['label' => __('dashboard.nav_group.contract'), 'items' => [ ['billing', 'box', 'billing', null], diff --git a/database/migrations/2026_08_03_150000_add_instances_manage_capability.php b/database/migrations/2026_08_03_150000_add_instances_manage_capability.php new file mode 100644 index 0000000..dee876e --- /dev/null +++ b/database/migrations/2026_08_03_150000_add_instances_manage_capability.php @@ -0,0 +1,47 @@ +forgetCachedPermissions(); + + Permission::findOrCreate('instances.manage', 'operator'); + foreach (['Owner', 'Admin', 'Support'] as $role) { + Role::findOrCreate($role, 'operator')->givePermissionTo('instances.manage'); + } + + app(PermissionRegistrar::class)->forgetCachedPermissions(); + } + + public function down(): void + { + app(PermissionRegistrar::class)->forgetCachedPermissions(); + Permission::query()->where('name', 'instances.manage')->where('guard_name', 'operator')->delete(); + app(PermissionRegistrar::class)->forgetCachedPermissions(); + } +}; diff --git a/lang/de/admin.php b/lang/de/admin.php index 103bd4d..3b4d39c 100644 --- a/lang/de/admin.php +++ b/lang/de/admin.php @@ -108,6 +108,32 @@ return [ 'host_silent' => 'Host :host hat sich seit über :minutes Minuten nicht gemeldet.', 'monitoring_down' => ':n überwachte Instanz(en) nicht erreichbar.', 'readiness_blocking' => '{1} :n Punkt auf der Bereitschaftsseite blockiert den Livebetrieb.|[2,*] :n Punkte auf der Bereitschaftsseite blockieren den Livebetrieb.', + // Host- UND Instanz-Sperren zusammen — es gibt keine zentrale Liste + // beider Arten, deshalb je nachdem, was gerade aktiv ist, auf die + // Host- oder die Instanzliste verlinkt (siehe Overview::notices()). + 'security_blocks' => ':n aktive Sicherheitssperre(n) — Adressen, die wegen Fehlversuchen ausgesperrt wurden.', + ], + + // Geteilt zwischen der Host-Detailseite (hosts.manage) und der + // Kundenseite (instances.manage) — dieselbe Sperre, zwei Orte, ein + // Wortlaut statt zweier, die auseinanderlaufen. + 'security_block' => [ + 'title' => 'Sicherheitssperren', + 'empty' => 'Keine Sperren.', + 'col_ip' => 'Adresse', + 'col_blocked' => 'Gesperrt seit', + 'col_expires' => 'Läuft ab', + 'col_attempts' => 'Fehlversuche', + 'col_status' => 'Status', + 'col_actions' => 'Aktion', + 'status_active' => 'Aktiv', + 'status_released' => 'Aufgehoben', + 'status_expired' => 'Abgelaufen', + 'release' => 'Aufheben', + 'released' => 'Sperre aufgehoben.', + 'release_title' => 'Sperre aufheben?', + 'release_body' => 'Die Adresse :ip wird sofort wieder zugelassen.', + 'release_confirm' => 'Aufheben', ], 'customers_sub' => 'Alle Kunden und ihre Pakete.', diff --git a/lang/de/dashboard.php b/lang/de/dashboard.php index 6c75aec..3ad692c 100644 --- a/lang/de/dashboard.php +++ b/lang/de/dashboard.php @@ -141,6 +141,7 @@ return [ 'domain' => 'Eigene Domain', 'users' => 'Benutzer', 'backups' => 'Backups', + 'security' => 'Sicherheit', 'invoices' => 'Rechnungen', 'billing' => 'Paket & Addons', 'settings' => 'Einstellungen', diff --git a/lang/de/roles.php b/lang/de/roles.php index 268318f..e9d1147 100644 --- a/lang/de/roles.php +++ b/lang/de/roles.php @@ -28,6 +28,7 @@ return [ 'dpa.manage' => 'Auftragsverarbeitungsverträge', 'hosts.manage' => 'Hosts anlegen, übernehmen, entfernen', 'instances.adminlogin' => 'Sich in die Cloud eines Kunden einloggen', + 'instances.manage' => 'Sicherheitssperren einer Kunden-Instanz sehen und aufheben', 'instances.restart' => 'Eine Kunden-Cloud neu starten', 'mail.manage' => 'Postfächer, Vorlagen, Posteingang', 'maintenance.manage' => 'Wartungsfenster und Störungen', diff --git a/lang/de/security.php b/lang/de/security.php index c8ed2f9..d4e8fc8 100644 --- a/lang/de/security.php +++ b/lang/de/security.php @@ -59,4 +59,26 @@ return [ // Die Fehlalarme, die bleiben, ausdrücklich benannt — sonst hält der // Empfänger die Meldung für falsch und nimmt die nächste nicht mehr ernst. 'mail_false_alarm' => 'Diese Meldung kommt auch, wenn ein eigenes Gerät oder eine eigene Anwendung mit einem falschen Passwort auf Ihre Cloud zugreift.', + + // Aufgabe 6: die Sicherheitsseite selbst — App\Livewire\Security, das Ziel + // hinter dem Knopf 'mail_action' oben und dem neuen Portal-Navigationspunkt. + 'blocks_title' => 'Sicherheitssperren', + 'blocks_lead' => 'Adressen, die wegen wiederholter Fehlversuche vorübergehend von Ihrer Cloud ausgesperrt wurden. Eine eigene Sperre — etwa nach einem falsch gespeicherten Passwort — können Sie hier vorzeitig aufheben.', + 'blocks_active' => 'Aktive Sperren', + 'blocks_empty' => 'Zurzeit ist keine Adresse an Ihrer Cloud gesperrt.', + 'blocks_history' => 'Frühere Sperren', + 'blocks_history_empty' => 'Noch keine früheren Sperren.', + 'col_ip' => 'Adresse', + 'col_blocked' => 'Gesperrt seit', + 'col_expires' => 'Läuft ab', + 'col_attempts' => 'Fehlversuche', + 'col_status' => 'Status', + 'col_actions' => 'Aktion', + 'status_released' => 'Aufgehoben', + 'status_expired' => 'Abgelaufen', + 'release' => 'Aufheben', + 'release_title' => 'Sperre aufheben?', + 'release_body' => 'Die Adresse :ip wird sofort wieder zugelassen. Heben Sie eine Sperre nur auf, wenn Sie sicher sind, dass die Anmeldeversuche von Ihnen selbst stammten.', + 'release_confirm' => 'Aufheben', + 'blocks_released' => 'Sperre aufgehoben.', ]; diff --git a/lang/en/admin.php b/lang/en/admin.php index 65eca87..2bfb723 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -108,6 +108,32 @@ return [ 'host_silent' => 'Host :host has not checked in for over :minutes minutes.', 'monitoring_down' => ':n monitored instance(s) unreachable.', 'readiness_blocking' => '{1} :n item on the readiness page blocks going live.|[2,*] :n items on the readiness page block going live.', + // Host AND instance blocks together — there is no single list of + // both kinds, so this links to whichever list currently applies + // (see Overview::notices()). + 'security_blocks' => ':n active security block(s) — addresses locked out after failed sign-in attempts.', + ], + + // Shared between the host detail page (hosts.manage) and the customer + // page (instances.manage) — the same kind of block, two places, one + // wording instead of two that drift apart. + 'security_block' => [ + 'title' => 'Security blocks', + 'empty' => 'No blocks.', + 'col_ip' => 'Address', + 'col_blocked' => 'Blocked since', + 'col_expires' => 'Expires', + 'col_attempts' => 'Failed attempts', + 'col_status' => 'Status', + 'col_actions' => 'Action', + 'status_active' => 'Active', + 'status_released' => 'Lifted', + 'status_expired' => 'Expired', + 'release' => 'Lift', + 'released' => 'Block lifted.', + 'release_title' => 'Lift this block?', + 'release_body' => 'Address :ip will be allowed through again immediately.', + 'release_confirm' => 'Lift', ], 'customers_sub' => 'All customers and their plans.', diff --git a/lang/en/dashboard.php b/lang/en/dashboard.php index 213e2b2..7ba9f09 100644 --- a/lang/en/dashboard.php +++ b/lang/en/dashboard.php @@ -141,6 +141,7 @@ return [ 'domain' => 'Your domain', 'users' => 'Users', 'backups' => 'Backups', + 'security' => 'Security', 'invoices' => 'Invoices', 'billing' => 'Plan & add-ons', 'settings' => 'Settings', diff --git a/lang/en/roles.php b/lang/en/roles.php index cba9a4e..e68ae2c 100644 --- a/lang/en/roles.php +++ b/lang/en/roles.php @@ -28,6 +28,7 @@ return [ 'dpa.manage' => 'Data processing agreements', 'hosts.manage' => 'Create, onboard and remove hosts', 'instances.adminlogin' => 'Sign in to a customer cloud', + 'instances.manage' => 'See and lift security blocks on a customer instance', 'instances.restart' => 'Restart a customer cloud', 'mail.manage' => 'Mailboxes, templates, inbox', 'maintenance.manage' => 'Maintenance windows and incidents', diff --git a/lang/en/security.php b/lang/en/security.php index e6fd425..6062240 100644 --- a/lang/en/security.php +++ b/lang/en/security.php @@ -59,4 +59,26 @@ return [ // The false alarms that remain, said out loud — otherwise the recipient // decides the warning is wrong and stops reading the next one. 'mail_false_alarm' => 'You will also get this message if a device or application of your own is using an outdated password against your cloud.', + + // Task 6: the security page itself — App\Livewire\Security, the target + // behind the 'mail_action' button above and the new portal nav entry. + 'blocks_title' => 'Security blocks', + 'blocks_lead' => 'Addresses temporarily locked out of your cloud after repeated failed sign-in attempts. You can lift your own block early here — for example after a saved password that turned out to be wrong.', + 'blocks_active' => 'Active blocks', + 'blocks_empty' => 'No address is currently blocked at your cloud.', + 'blocks_history' => 'Past blocks', + 'blocks_history_empty' => 'No past blocks yet.', + 'col_ip' => 'Address', + 'col_blocked' => 'Blocked since', + 'col_expires' => 'Expires', + 'col_attempts' => 'Failed attempts', + 'col_status' => 'Status', + 'col_actions' => 'Action', + 'status_released' => 'Lifted', + 'status_expired' => 'Expired', + 'release' => 'Lift', + 'release_title' => 'Lift this block?', + 'release_body' => 'Address :ip will be allowed through again immediately. Only lift a block if you are sure the sign-in attempts were your own.', + 'release_confirm' => 'Lift', + 'blocks_released' => 'Block lifted.', ]; diff --git a/resources/views/livewire/admin/confirm-release-block.blade.php b/resources/views/livewire/admin/confirm-release-block.blade.php new file mode 100644 index 0000000..8705027 --- /dev/null +++ b/resources/views/livewire/admin/confirm-release-block.blade.php @@ -0,0 +1,17 @@ +
+
+ + + +
+

{{ __('admin.security_block.release_title') }}

+

{{ __('admin.security_block.release_body', ['ip' => $ip]) }}

+
+
+
+ {{ __('common.cancel') }} + + {{ __('admin.security_block.release_confirm') }} + +
+
diff --git a/resources/views/livewire/admin/customer-detail.blade.php b/resources/views/livewire/admin/customer-detail.blade.php index 6a3a9a9..2c4a443 100644 --- a/resources/views/livewire/admin/customer-detail.blade.php +++ b/resources/views/livewire/admin/customer-detail.blade.php @@ -220,6 +220,61 @@ @endif + + {{-- Sicherheitssperren dieser Instanz. Denselben Abschnitt + (Zeilen + Knopf) zeigt der Host auf seiner eigenen + Detailseite, hinter hosts.manage statt + instances.manage — siehe admin.security_block im + Sprachfile. --}} + @can('instances.manage') + @if ($instance !== null) +
+

+ {{ __('admin.security_block.title') }} + {{ $securityBlocks->count() }} +

+ @if ($securityBlocks->isEmpty()) +

{{ __('admin.security_block.empty') }}

+ @else +
+ + + + + + + + + + + @foreach ($securityBlocks as $block) + @php + $active = $block->released_at === null && $block->expires_at->isFuture(); + $bstatus = $active ? 'warning' : ($block->released_at ? 'active' : 'info'); + $blabel = $active ? 'status_active' : ($block->released_at ? 'status_released' : 'status_expired'); + @endphp + + + + + + + @endforeach + +
{{ __('admin.security_block.col_ip') }}{{ __('admin.security_block.col_blocked') }}{{ __('admin.security_block.col_status') }}{{ __('admin.security_block.col_actions') }}
{{ $block->ip }}{{ $block->blocked_at->local()->isoFormat('DD.MM. HH:mm') }}{{ __('admin.security_block.'.$blabel) }} + @if ($active) + + @endif +
+
+ @endif +
+ @endif + @endcan @endif diff --git a/resources/views/livewire/admin/host-detail.blade.php b/resources/views/livewire/admin/host-detail.blade.php index de826e0..0a6fcc1 100644 --- a/resources/views/livewire/admin/host-detail.blade.php +++ b/resources/views/livewire/admin/host-detail.blade.php @@ -279,6 +279,57 @@ @endif + {{-- Sicherheitssperren dieses Hosts. Denselben Abschnitt (Zeilen + Knopf) + zeigt die Instanz auf der Kundenseite, hinter instances.manage statt + hosts.manage — siehe admin.security_block im Sprachfile. --}} + @can('hosts.manage') +
+

+ {{ __('admin.security_block.title') }} + {{ $securityBlocks->count() }} +

+ @if ($securityBlocks->isEmpty()) +

{{ __('admin.security_block.empty') }}

+ @else +
+ + + + + + + + + + + @foreach ($securityBlocks as $block) + @php + $active = $block->released_at === null && $block->expires_at->isFuture(); + $bstatus = $active ? 'warning' : ($block->released_at ? 'active' : 'info'); + $blabel = $active ? 'status_active' : ($block->released_at ? 'status_released' : 'status_expired'); + @endphp + + + + + + + @endforeach + +
{{ __('admin.security_block.col_ip') }}{{ __('admin.security_block.col_blocked') }}{{ __('admin.security_block.col_status') }}{{ __('admin.security_block.col_actions') }}
{{ $block->ip }}{{ $block->blocked_at->local()->isoFormat('DD.MM. HH:mm') }}{{ __('admin.security_block.'.$blabel) }} + @if ($active) + + @endif +
+
+ @endif +
+ @endcan + @if ($run && $run->status === 'failed')
diff --git a/resources/views/livewire/confirm-release-block.blade.php b/resources/views/livewire/confirm-release-block.blade.php new file mode 100644 index 0000000..6723d3c --- /dev/null +++ b/resources/views/livewire/confirm-release-block.blade.php @@ -0,0 +1,17 @@ +
+
+ + + +
+

{{ __('security.release_title') }}

+

{{ __('security.release_body', ['ip' => $ip]) }}

+
+
+
+ {{ __('common.cancel') }} + + {{ __('security.release_confirm') }} + +
+
diff --git a/resources/views/livewire/security.blade.php b/resources/views/livewire/security.blade.php new file mode 100644 index 0000000..b5388c6 --- /dev/null +++ b/resources/views/livewire/security.blade.php @@ -0,0 +1,87 @@ +
+
+

{{ __('security.blocks_title') }}

+

{{ __('security.blocks_lead') }}

+
+ + {{-- Aktive Sperren --}} +
+
+

{{ __('security.blocks_active') }}

+
+ @if ($active->isEmpty()) +

{{ __('security.blocks_empty') }}

+ @else +
+ + + + + + + + + + + + @foreach ($active as $block) + + + + + + + + @endforeach + +
{{ __('security.col_ip') }}{{ __('security.col_blocked') }}{{ __('security.col_expires') }}{{ __('security.col_attempts') }}{{ __('security.col_actions') }}
{{ $block->ip }}{{ $block->blocked_at->local()->isoFormat('DD.MM. HH:mm') }}{{ $block->expires_at->local()->isoFormat('DD.MM. HH:mm') }}{{ $block->attempts }} + {{-- Oeffnet ConfirmReleaseBlock statt hier selbst + freizugeben — Bestaetigung im Modal, nie ein + nativer Browser-Dialog (R23). --}} + +
+
+ @endif +
+ + {{-- Fruehere Sperren: abgelaufen von selbst oder vorzeitig aufgehoben. --}} +
+
+

{{ __('security.blocks_history') }}

+
+ @if ($history->isEmpty()) +

{{ __('security.blocks_history_empty') }}

+ @else +
+ + + + + + + + + + @foreach ($history as $block) + + + + + + @endforeach + +
{{ __('security.col_ip') }}{{ __('security.col_blocked') }}{{ __('security.col_status') }}
{{ $block->ip }}{{ $block->blocked_at->local()->isoFormat('DD.MM. HH:mm') }} + @if ($block->released_at) + {{ __('security.status_released') }} + @else + {{ __('security.status_expired') }} + @endif +
+
+ @endif +
+
diff --git a/routes/web.php b/routes/web.php index b9e8de6..c5141c8 100644 --- a/routes/web.php +++ b/routes/web.php @@ -23,6 +23,7 @@ use App\Livewire\CustomDomain; use App\Livewire\Dashboard; use App\Livewire\Invoices; use App\Livewire\Order; +use App\Livewire\Security; use App\Livewire\Support; use App\Livewire\Users; use App\Models\Customer; @@ -354,10 +355,9 @@ $portal = function () { Route::get('/backups', Backups::class)->name('backups'); Route::get('/invoices', Invoices::class)->name('invoices'); - // Platzhalter fuer Aufgabe 6, die hier die eigentliche Sicherheitsseite - // (Sperrliste der eigenen Instanzen) anlegt. Ohne einen benannten - // 'portal.security' bricht schon heute MailPreviewTest, weil - // SecurityBlockMail::content() dorthin verlinkt (route('portal.security')). + // Die Sperrliste der eigenen Instanzen — das Ziel, auf das die Mail aus + // BlockAddress verlinkt (SecurityBlockMail::content() ruft + // route('portal.security')). // // NICHT '/security': die öffentliche Seite (oben, Zeile ~254) meldet // denselben Pfad an, und $appHost/$siteHost sind hier beide leer (jede @@ -366,7 +366,9 @@ $portal = function () { // Routen über Methode+Domain+URI, NICHT über den Namen. Zwei GET-Routen // auf demselben Pfad ohne Domain überschreiben sich also gegenseitig, // unabhängig vom Namen — die zweite gewinnt lautlos, ganz ohne Fehler. - Route::get('/security-blocks', fn () => redirect()->route('dashboard'))->name('portal.security'); + // Der PFAD bleibt deshalb '/security-blocks', der NAME 'portal.security' + // (die vertraglich fixierte Schnittstelle, die die Mail benutzt). + Route::get('/security-blocks', Security::class)->name('portal.security'); // The customer's own invoice as a PDF, rendered on demand from the frozen // document — the same renderer the console uses, because there is only one diff --git a/tests/Feature/Admin/RbacMoveTest.php b/tests/Feature/Admin/RbacMoveTest.php index 87af37f..def9f66 100644 --- a/tests/Feature/Admin/RbacMoveTest.php +++ b/tests/Feature/Admin/RbacMoveTest.php @@ -13,10 +13,10 @@ it('moves every permission and role to the operator guard, leaving none behind', expect(Permission::where('guard_name', 'web')->count())->toBe(0) ->and(Role::where('guard_name', 'web')->count())->toBe(0) // 17 from the original seed, plus customers.grant_plan, - // instances.restart and dpa.manage — the later ones created straight - // onto this guard, because since this migration `web` is where a - // permission goes to match nobody at all. - ->and(Permission::where('guard_name', 'operator')->count())->toBe(20) + // instances.restart, dpa.manage and instances.manage — the later + // ones created straight onto this guard, because since this + // migration `web` is where a permission goes to match nobody at all. + ->and(Permission::where('guard_name', 'operator')->count())->toBe(21) ->and(Role::where('guard_name', 'operator')->count())->toBe(6); }); @@ -159,7 +159,7 @@ it('preflights every customer conflict before mutating anything, listing all of // server, which is the whole reason this has to be checked up front. // Every capability this installation has, pushed back to `web` above to // stage the pre-migration shape — the figure grows with each new one. - expect(Permission::where('guard_name', 'web')->count())->toBe(20) + expect(Permission::where('guard_name', 'web')->count())->toBe(21) ->and(Permission::where('guard_name', 'operator')->count())->toBe(0) ->and(Role::where('guard_name', 'web')->count())->toBe(6) ->and(Role::where('guard_name', 'operator')->count())->toBe(0) diff --git a/tests/Feature/Mail/MailLaneRoutingTest.php b/tests/Feature/Mail/MailLaneRoutingTest.php new file mode 100644 index 0000000..8f356d8 --- /dev/null +++ b/tests/Feature/Mail/MailLaneRoutingTest.php @@ -0,0 +1,82 @@ + 'CluPilot Cloud e.U.', + 'address' => 'Dreherstraße 66/1/8', + 'postcode' => '1110', + 'city' => 'Wien', + 'vat_id' => 'ATU00000000', + ]); + + $customer = Customer::factory()->create(['name' => 'Muster GmbH']); + $orders = collect([Order::factory()->create([ + 'customer_id' => $customer->id, 'amount_cents' => 1900, 'currency' => 'EUR', 'status' => 'paid', + ])]); + + return app(IssueInvoice::class)->forOrders($customer, $orders); +} + +it('reiht jede Mail in die Schlange ihrer Spur ein', function () { + Queue::fake(); + + Mail::to('kunde@example.test')->queue(new InvoiceMail(invoiceForTest(), 'Muster')); + + Queue::assertPushedOn(MailLane::CALM, Illuminate\Mail\SendQueuedMailable::class); +}); + +it('reiht eine Direkt-Mail in die Direkt-Spur ein', function () { + Queue::fake(); + + $user = User::factory()->create(); + + Mail::to('kunde@example.test')->queue(new ResetPasswordMail($user, 'https://example.test/reset', 60)); + + Queue::assertPushedOn(MailLane::DIRECT, Illuminate\Mail\SendQueuedMailable::class); +}); + +it('folgt einer verschobenen Zuordnung', function () { + Queue::fake(); + MailLane::assign(InvoiceMail::class, MailLane::URGENT); + + Mail::to('kunde@example.test')->queue(new InvoiceMail(invoiceForTest(), 'Muster')); + + Queue::assertPushedOn(MailLane::URGENT, Illuminate\Mail\SendQueuedMailable::class); +}); + +it('lässt keine Mailklasse ohne Spur', function () { + $missing = collect(glob(app_path('Mail/*.php'))) + ->map(fn (string $path) => 'App\\Mail\\'.basename($path, '.php')) + ->reject(fn (string $class) => in_array(App\Mail\Concerns\RidesALane::class, class_uses_recursive($class), true)) + ->values() + ->all(); + + expect($missing)->toBe([]); +}); diff --git a/tests/Feature/Security/SecurityConsoleTest.php b/tests/Feature/Security/SecurityConsoleTest.php new file mode 100644 index 0000000..127a7eb --- /dev/null +++ b/tests/Feature/Security/SecurityConsoleTest.php @@ -0,0 +1,129 @@ +active()->create(); + $block = SecurityBlock::factory()->forHost($host)->create(['ip' => '203.0.113.20']); + + Livewire::actingAs(operator('Admin'), 'operator') + ->test(HostDetail::class, ['host' => $host]) + ->assertSee('203.0.113.20') + ->call('onSecurityBlockReleaseConfirmed', $block->uuid) + ->assertDispatched('notify'); + + expect($block->fresh()->released_at)->not->toBeNull(); +}); + +it('verweigert das Aufheben einer Host-Sperre ohne hosts.manage', function () { + $host = Host::factory()->active()->create(); + $block = SecurityBlock::factory()->forHost($host)->create(); + + Livewire::actingAs(operator('Read-only'), 'operator') + ->test(HostDetail::class, ['host' => $host]) + ->call('onSecurityBlockReleaseConfirmed', $block->uuid) + ->assertForbidden(); + + expect($block->fresh()->released_at)->toBeNull(); +}); + +it('laesst eine Host-Sperre nicht ueber die Instanz-Seite eines Kunden aufheben', function () { + // Zwei getrennte Subjekte, zwei getrennte Pruefungen: CustomerDetail darf + // nur Sperren an EIGENEN Instanzen aufheben, nie eine Host-Sperre — auch + // nicht mit instances.manage. + $host = Host::factory()->active()->create(); + $block = SecurityBlock::factory()->forHost($host)->create(); + $customer = Customer::factory()->create(); + Instance::factory()->for($customer)->create(); + + Livewire::actingAs(operator('Admin'), 'operator') + ->test(CustomerDetail::class, ['uuid' => $customer->uuid]) + ->call('onSecurityBlockReleaseConfirmed', $block->uuid); + + expect($block->fresh()->released_at)->toBeNull(); +}); + +it('zeigt einem Betreiber mit instances.manage die Sperre einer Kunden-Instanz und laesst ihn sie aufheben', function () { + $customer = Customer::factory()->create(); + $instance = Instance::factory()->for($customer)->create(); + $block = SecurityBlock::factory()->for($instance)->create(['ip' => '198.51.100.42']); + + Livewire::actingAs(operator('Support'), 'operator') + ->test(CustomerDetail::class, ['uuid' => $customer->uuid]) + ->call('onSecurityBlockReleaseConfirmed', $block->uuid) + ->assertDispatched('notify'); + + expect($block->fresh()->released_at)->not->toBeNull(); +}); + +it('verweigert das Aufheben einer Instanz-Sperre ohne instances.manage', function () { + // Keine der fuenf mitgelieferten Rollen eignet sich hier: Owner, Admin und + // Support tragen instances.manage genau wie customers.manage (dieselbe + // Begruendung wie bei instances.restart), Billing und Read-only haben + // nicht einmal customers.manage und koennten die Seite gar nicht oeffnen. + // Also die eigene, engere Rolle, die die Rollen-Konsole genau dafuer + // vorsieht (Muster: tests/Feature/Admin/RoleManagementTest.php). + $role = Role::findOrCreate('Kundendienst-eng', 'operator'); + $role->syncPermissions(['console.view', 'customers.manage']); + $support = Operator::factory()->create(['password' => 'password']); + $support->assignRole($role); + + $customer = Customer::factory()->create(); + $instance = Instance::factory()->for($customer)->create(); + $block = SecurityBlock::factory()->for($instance)->create(); + + Livewire::actingAs($support, 'operator') + ->test(CustomerDetail::class, ['uuid' => $customer->uuid]) + ->call('onSecurityBlockReleaseConfirmed', $block->uuid) + ->assertForbidden(); + + expect($block->fresh()->released_at)->toBeNull(); +}); + +it('autorisiert das geteilte Konsolen-Modal nach dem Subjekt der Sperre', function () { + $host = Host::factory()->active()->create(); + $hostBlock = SecurityBlock::factory()->forHost($host)->create(); + + $customer = Customer::factory()->create(); + $instance = Instance::factory()->for($customer)->create(); + $instanceBlock = SecurityBlock::factory()->for($instance)->create(); + + // hosts.manage oeffnet die Host-Sperre, aber nicht die Instanz-Sperre. + Livewire::actingAs(operator('Admin'), 'operator') + ->test(ConfirmReleaseBlock::class, ['uuid' => $hostBlock->uuid]) + ->assertSee($hostBlock->ip); + + Livewire::actingAs(operator('Read-only'), 'operator') + ->test(ConfirmReleaseBlock::class, ['uuid' => $instanceBlock->uuid]) + ->assertForbidden(); +}); + +it('zeigt auf der Uebersicht einen Hinweis, solange irgendwo eine Sperre aktiv ist', function () { + Livewire::actingAs(operator('Owner'), 'operator') + ->test(Overview::class) + ->assertDontSee(__('admin.notice.security_blocks', ['n' => 1])); + + $instance = Instance::factory()->create(); + SecurityBlock::factory()->for($instance)->create(); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(Overview::class) + ->assertSee(__('admin.notice.security_blocks', ['n' => 1])); +}); diff --git a/tests/Feature/Security/SecurityPageTest.php b/tests/Feature/Security/SecurityPageTest.php new file mode 100644 index 0000000..45a84fb --- /dev/null +++ b/tests/Feature/Security/SecurityPageTest.php @@ -0,0 +1,60 @@ +create() verknuepft +// keinen User (user_id bleibt NULL, siehe CustomerFactory) — jeder bestehende +// Test, der einen Kunden anmeldet, ruft dafuer ensureUser() (Muster: +// tests/Feature/CustomerTwoFactorTest.php::portalUser()). Der RUECKGABEWERT +// wird gebraucht, nicht $customer->user danach: ensureUser() selbst liest +// $this->user als ersten Schritt ("if ($this->user) return $this->user;"), +// bevor user_id gesetzt ist — das cacht die Beziehung als null, und ein +// spaeteres update() raeumt einen bereits geladenen Beziehungs-Cache nicht +// weg. $customer->user bliebe also null, obwohl user_id laengst gesetzt ist. + +it('zeigt dem Inhaber die Sperren seiner eigenen Instanz', function () { + $customer = Customer::factory()->create(); + $user = $customer->ensureUser(); + $instance = Instance::factory()->for($customer)->create(); + $block = SecurityBlock::factory()->for($instance)->create(['ip' => '203.0.113.7']); + + Livewire::actingAs($user)->test(Security::class)->assertSee('203.0.113.7'); +}); + +it('zeigt einem Inhaber die Sperren eines FREMDEN Kunden nicht', function () { + $meine = Customer::factory()->create(); + $user = $meine->ensureUser(); + $fremde = Instance::factory()->create(); + SecurityBlock::factory()->for($fremde)->create(['ip' => '198.51.100.9']); + + Livewire::actingAs($user)->test(Security::class)->assertDontSee('198.51.100.9'); +}); + +it('laesst einen Inhaber eine fremde Sperre nicht aufheben', function () { + $meine = Customer::factory()->create(); + $user = $meine->ensureUser(); + $fremd = SecurityBlock::factory()->for(Instance::factory()->create())->create(); + + Livewire::actingAs($user)->test(Security::class) + ->call('onReleaseConfirmed', $fremd->uuid) + ->assertForbidden(); + + expect($fremd->fresh()->released_at)->toBeNull(); +}); + +it('hebt eine eigene Sperre auf und traegt ein, wer es war', function () { + $customer = Customer::factory()->create(); + $user = $customer->ensureUser(); + $instance = Instance::factory()->for($customer)->create(); + $block = SecurityBlock::factory()->for($instance)->create(); + + Livewire::actingAs($user)->test(Security::class) + ->call('onReleaseConfirmed', $block->uuid); + + expect($block->fresh()->released_at)->not->toBeNull() + ->and($block->fresh()->released_by_id)->toBe($user->id); +});