Sperren sehen und aufheben: Portalseite fuer den Inhaber, Abschnitte in der Konsole

Aufgabe 6 des Fruehwarnsystems. Der Inhaber sieht im Portal die Sperren SEINER
Instanzen und hebt sie dort auf; der Betreiber sieht in der Konsole alle, an der
Kunden- und an der Host-Detailseite, und auf der Uebersicht steht ein Hinweis,
solange irgendwo eine Sperre aktiv ist.

Aufgehoben wird ueber ein Bestaetigungs-Modal (R23), und das Modal mutiert
nichts: es wirft ein Ereignis, das die Seite auffaengt und an ihre eigene
Methode weiterreicht. Die Berechtigungspruefung bleibt damit an der einen
Stelle, an der sie schon stand — noetig, weil ein Modal ohne die Middleware der
Seite erreichbar ist (R20). Dazu die Berechtigung `instances.manage`, nach dem
Muster der bestehenden `instances.restart`-Migration; Abrechnung und Read-only
bleiben unberuehrt.

ACHTUNG, was hier sonst noch drinsteckt und NICHT zu dieser Aufgabe gehoert:
rund 150 Zeilen zum Versandtakt — das Merkmal `RidesALane`, vierzehn Mailables
und `MailLaneRoutingTest`. Die stammen aus einer PARALLEL laufenden Sitzung an
einem anderen Feature.

Wie das hineingeriet: der Implementierer dieser Aufgabe brach vor dem Commit ab
und liess seine fertige Arbeit ungespeichert im Baum. Ich habe sie dateigenau
mit `git add <dateien>` vorgemerkt, um nichts Fremdes mitzunehmen — und dabei
uebersehen, dass `git add` nur HINZUFUEGT: die andere Sitzung hatte ihre Arbeit
bereits vorgemerkt, und `git commit` schreibt den ganzen Index, nicht nur das
zuletzt Hinzugefuegte. Richtig waere `git commit -- <dateien>` gewesen.

Nichts ist verloren, und die volle Suite ist auf diesem Stand gruen (2571).
Aber diese Botschaft soll nicht behaupten, sie beschreibe alles, was hier steht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/versandtakt
nexxo 2026-08-03 15:42:20 +02:00
parent 4db1957f06
commit 51a97019a8
41 changed files with 971 additions and 28 deletions

View File

@ -0,0 +1,51 @@
<?php
namespace App\Livewire\Admin;
use App\Models\SecurityBlock;
use LivewireUI\Modal\ModalComponent;
/**
* Bestaetigung vorm Aufheben einer Sperre in der Konsole (R23).
*
* Eine Sperre, zwei Orte die Host-Detailseite (hosts.manage) und die
* Instanz eines Kunden auf dessen Kundenseite (instances.manage) ein
* gemeinsames Modal, das die Berechtigung nach dem Subjekt der Sperre
* entscheidet, statt zwei fast identische Modals zu pflegen.
*
* Mutiert trotzdem nichts: HostDetail::onSecurityBlockReleaseConfirmed() bzw.
* CustomerDetail::onSecurityBlockReleaseConfirmed() pruefen beim
* tatsaechlichen Aufheben ihrerseits noch einmal, dass die Sperre zu IHREM
* Host bzw. IHRER Instanz gehoert ein Modal ist ohne die Route-Middleware
* der Seite erreichbar (R20), die Pruefung hier ist eine Anzeige-Schranke
* (welche Adresse dieses Fenster nennen darf), keine Ersatz-Autorisierung.
*/
class ConfirmReleaseBlock extends ModalComponent
{
public string $uuid;
public string $ip = '';
public function mount(string $uuid): void
{
$block = SecurityBlock::query()->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');
}
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,52 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\SecurityBlock;
use LivewireUI\Modal\ModalComponent;
/**
* Bestaetigung vorm Aufheben einer eigenen Sperre (R23).
*
* Mutiert nichts: Security::onReleaseConfirmed() bleibt die einzige Stelle,
* die tatsaechlich freigibt und die eigene Pruefung dort wiederholt genau
* das, was hier schon steht, weil ein Modal ohne die Route-Middleware der
* Seite erreichbar ist (R20). Deshalb loest auch dieses Modal den Kunden
* selbst auf, statt der uebergebenen uuid zu vertrauen, und zeigt bei einer
* fremden oder einer Host-Sperre lieber 404 als deren Adresse.
*/
class ConfirmReleaseBlock extends ModalComponent
{
use ResolvesCustomer;
public string $uuid;
public string $ip = '';
public function mount(string $uuid): void
{
$customer = $this->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');
}
}

70
app/Livewire/Security.php Normal file
View File

@ -0,0 +1,70 @@
<?php
namespace App\Livewire;
use App\Livewire\Concerns\ResolvesCustomer;
use App\Models\SecurityBlock;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
/**
* Die Sperrliste der eigenen Instanzen das Ziel, auf das die Mail aus
* BlockAddress verlinkt (route('portal.security'), siehe deren
* SecurityBlockMail), und der Ort, an dem ein Kunde eine eigene
* Fehlalarm-Sperre selbst vorzeitig aufheben kann.
*/
#[Layout('layouts.portal-app')]
class Security extends Component
{
use ResolvesCustomer;
/**
* ConfirmReleaseBlock dispatches this back (R23) das Modal selbst
* mutiert nichts. Es ist ohne die Middleware dieser Seite erreichbar
* (R20), deshalb wird hier wie im Modal auch, aber unabhaengig davon
* erneut geprueft, dass die Sperre zu einer Instanz DIESES Kunden
* gehoert, statt der uebergebenen uuid zu vertrauen. Eine fremde oder
* eine Host-Sperre (kein instance_id) gibt es hier nie zu sehen.
*/
#[On('block-release-confirmed')]
public function onReleaseConfirmed(string $uuid): void
{
$customer = $this->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(),
]);
}
}

View File

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

View File

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

View File

@ -0,0 +1,27 @@
<?php
namespace App\Mail\Concerns;
use App\Services\Mail\MailLane;
use Illuminate\Contracts\Queue\Factory as Queue;
/**
* Reiht eine Mail in die Schlange ihrer Spur ein.
*
* `Mailable::queue()` liest den Schlangennamen und reicht ihn an `pushOn()`
* weiter die Spur muss also VOR dem Einreihen feststehen. Eine Trait-Methode
* schlägt die geerbte Methode der Elternklasse, deshalb genügt es, `queue()`
* hier zu überschreiben und danach an die Elternklasse weiterzugeben.
*
* Neben `SendsFromMailbox` statt darin: die eine Sache ist, von welchem
* Postfach eine Mail kommt, die andere, wie eilig sie ist.
*/
trait RidesALane
{
public function queue(Queue $queue)
{
$this->onQueue(MailLane::for(static::class));
return parent::queue($queue);
}
}

View File

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

View File

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

View File

@ -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, 13 = Mahnstufe

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,47 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
/**
* `instances.manage` see and lift a security block sitting on a customer's
* instance (App\Livewire\Admin\CustomerDetail, the "package" tab).
*
* No such capability existed: SecurityBlock and its release() method were
* built in an earlier task, but nothing in the console could reach either yet
* this is that view, and it needs its own gate the way the matching
* host-side section already leans on `hosts.manage`.
*
* Granted the same three roles as `instances.restart` (see that migration),
* for the same reason stated there: a customer locked out by their own typo
* is exactly the call Support answers the phone for, and an Admin who has to
* fetch an Owner to press one button is an outage that lasts until somebody
* answers.
*
* Guard `operator`: every capability in this application lives there since
* 2026-07-29 (see move_rbac_to_operator_guard) one created under `web`
* would silently match nobody at all.
*/
return new class extends Migration
{
public function up(): void
{
app(PermissionRegistrar::class)->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();
}
};

View File

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

View File

@ -141,6 +141,7 @@ return [
'domain' => 'Eigene Domain',
'users' => 'Benutzer',
'backups' => 'Backups',
'security' => 'Sicherheit',
'invoices' => 'Rechnungen',
'billing' => 'Paket & Addons',
'settings' => 'Einstellungen',

View File

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

View File

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

View File

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

View File

@ -141,6 +141,7 @@ return [
'domain' => 'Your domain',
'users' => 'Users',
'backups' => 'Backups',
'security' => 'Security',
'invoices' => 'Invoices',
'billing' => 'Plan & add-ons',
'settings' => 'Settings',

View File

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

View File

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

View File

@ -0,0 +1,17 @@
<div class="rounded-lg bg-surface p-6">
<div class="flex items-start gap-3">
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-warning-bg text-warning">
<x-ui.icon name="unlock" class="size-5" />
</span>
<div class="min-w-0">
<h3 class="text-base font-semibold text-ink">{{ __('admin.security_block.release_title') }}</h3>
<p class="mt-1 text-sm text-muted">{{ __('admin.security_block.release_body', ['ip' => $ip]) }}</p>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('common.cancel') }}</x-ui.button>
<x-ui.button variant="primary" wire:click="confirm" wire:loading.attr="disabled">
{{ __('admin.security_block.release_confirm') }}
</x-ui.button>
</div>
</div>

View File

@ -220,6 +220,61 @@
</dl>
@endif
</div>
{{-- 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)
<div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:140ms]">
<h2 class="font-semibold text-ink">
{{ __('admin.security_block.title') }}
<span class="ml-1 font-mono text-xs font-normal text-muted">{{ $securityBlocks->count() }}</span>
</h2>
@if ($securityBlocks->isEmpty())
<p class="mt-3 text-sm text-muted">{{ __('admin.security_block.empty') }}</p>
@else
<div class="mt-3 overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-line text-left text-xs font-semibold text-muted">
<th class="py-2 pr-4 font-semibold">{{ __('admin.security_block.col_ip') }}</th>
<th class="py-2 pr-4 font-semibold">{{ __('admin.security_block.col_blocked') }}</th>
<th class="py-2 pr-4 font-semibold">{{ __('admin.security_block.col_status') }}</th>
<th class="py-2 text-right font-semibold">{{ __('admin.security_block.col_actions') }}</th>
</tr>
</thead>
<tbody>
@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
<tr wire:key="instance-block-{{ $block->uuid }}" class="border-b border-line last:border-0">
<td class="py-2.5 pr-4 font-mono text-ink">{{ $block->ip }}</td>
<td class="py-2.5 pr-4 font-mono text-xs text-muted">{{ $block->blocked_at->local()->isoFormat('DD.MM. HH:mm') }}</td>
<td class="py-2.5 pr-4"><x-ui.badge :status="$bstatus">{{ __('admin.security_block.'.$blabel) }}</x-ui.badge></td>
<td class="py-2.5 text-right">
@if ($active)
<button type="button"
x-on:click="$dispatch('openModal', { component: 'admin.confirm-release-block', arguments: { uuid: '{{ $block->uuid }}' } })"
class="rounded-md border border-line px-2.5 py-1.5 text-xs font-semibold text-muted hover:border-accent-border hover:text-accent-text">
{{ __('admin.security_block.release') }}
</button>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
@endif
@endcan
</div>
</div>
@endif

View File

@ -279,6 +279,57 @@
@endif
</div>
{{-- 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')
<div class="rounded-lg border border-line bg-surface p-5 shadow-xs animate-rise [animation-delay:110ms]">
<h2 class="mb-3 text-sm font-semibold text-ink">
{{ __('admin.security_block.title') }}
<span class="ml-1 font-mono text-xs font-normal text-muted">{{ $securityBlocks->count() }}</span>
</h2>
@if ($securityBlocks->isEmpty())
<p class="text-sm text-muted">{{ __('admin.security_block.empty') }}</p>
@else
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-line text-left text-xs font-semibold text-muted">
<th class="py-2 pr-4 font-semibold">{{ __('admin.security_block.col_ip') }}</th>
<th class="py-2 pr-4 font-semibold">{{ __('admin.security_block.col_blocked') }}</th>
<th class="py-2 pr-4 font-semibold">{{ __('admin.security_block.col_status') }}</th>
<th class="py-2 text-right font-semibold">{{ __('admin.security_block.col_actions') }}</th>
</tr>
</thead>
<tbody>
@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
<tr wire:key="host-block-{{ $block->uuid }}" class="border-b border-line last:border-0">
<td class="py-2.5 pr-4 font-mono text-ink">{{ $block->ip }}</td>
<td class="py-2.5 pr-4 font-mono text-xs text-muted">{{ $block->blocked_at->local()->isoFormat('DD.MM. HH:mm') }}</td>
<td class="py-2.5 pr-4"><x-ui.badge :status="$bstatus">{{ __('admin.security_block.'.$blabel) }}</x-ui.badge></td>
<td class="py-2.5 text-right">
@if ($active)
<button type="button"
x-on:click="$dispatch('openModal', { component: 'admin.confirm-release-block', arguments: { uuid: '{{ $block->uuid }}' } })"
class="rounded-md border border-line px-2.5 py-1.5 text-xs font-semibold text-muted hover:border-accent-border hover:text-accent-text">
{{ __('admin.security_block.release') }}
</button>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
@endcan
@if ($run && $run->status === 'failed')
<div class="flex items-start gap-3 rounded-lg border border-danger-border bg-danger-bg p-4 animate-rise">
<x-ui.icon name="alert-triangle" class="size-5 shrink-0 text-danger" />

View File

@ -0,0 +1,17 @@
<div class="rounded-lg bg-surface p-6">
<div class="flex items-start gap-3">
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-warning-bg text-warning">
<x-ui.icon name="unlock" class="size-5" />
</span>
<div class="min-w-0">
<h3 class="text-base font-semibold text-ink">{{ __('security.release_title') }}</h3>
<p class="mt-1 text-sm text-muted">{{ __('security.release_body', ['ip' => $ip]) }}</p>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('common.cancel') }}</x-ui.button>
<x-ui.button variant="primary" wire:click="confirm" wire:loading.attr="disabled">
{{ __('security.release_confirm') }}
</x-ui.button>
</div>
</div>

View File

@ -0,0 +1,87 @@
<div class="space-y-6">
<div class="animate-rise">
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('security.blocks_title') }}</h1>
<p class="mt-1 max-w-[65ch] text-sm text-muted">{{ __('security.blocks_lead') }}</p>
</div>
{{-- Aktive Sperren --}}
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
<div class="border-b border-line px-5 py-4">
<h2 class="text-sm font-semibold text-ink">{{ __('security.blocks_active') }}</h2>
</div>
@if ($active->isEmpty())
<p class="px-5 py-6 text-sm text-muted">{{ __('security.blocks_empty') }}</p>
@else
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-line bg-surface-2 text-left text-xs font-semibold text-muted">
<th class="px-4 py-3 font-semibold">{{ __('security.col_ip') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('security.col_blocked') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('security.col_expires') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('security.col_attempts') }}</th>
<th class="px-4 py-3 text-right font-semibold">{{ __('security.col_actions') }}</th>
</tr>
</thead>
<tbody>
@foreach ($active as $block)
<tr wire:key="active-{{ $block->uuid }}" class="border-b border-line last:border-0 hover:bg-surface-hover">
<td class="px-4 py-3 font-mono text-body">{{ $block->ip }}</td>
<td class="px-4 py-3 font-mono text-xs text-muted">{{ $block->blocked_at->local()->isoFormat('DD.MM. HH:mm') }}</td>
<td class="px-4 py-3 font-mono text-xs text-muted">{{ $block->expires_at->local()->isoFormat('DD.MM. HH:mm') }}</td>
<td class="px-4 py-3 font-mono text-xs text-muted">{{ $block->attempts }}</td>
<td class="px-4 py-3 text-right">
{{-- Oeffnet ConfirmReleaseBlock statt hier selbst
freizugeben Bestaetigung im Modal, nie ein
nativer Browser-Dialog (R23). --}}
<button type="button"
x-on:click="$dispatch('openModal', { component: 'confirm-release-block', arguments: { uuid: '{{ $block->uuid }}' } })"
class="rounded-md border border-line px-2.5 py-1.5 text-xs font-semibold text-muted hover:border-accent-border hover:text-accent-text">
<x-ui.icon name="unlock" class="size-4" />{{ __('security.release') }}
</button>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
{{-- Fruehere Sperren: abgelaufen von selbst oder vorzeitig aufgehoben. --}}
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:120ms]">
<div class="border-b border-line px-5 py-4">
<h2 class="text-sm font-semibold text-ink">{{ __('security.blocks_history') }}</h2>
</div>
@if ($history->isEmpty())
<p class="px-5 py-6 text-sm text-muted">{{ __('security.blocks_history_empty') }}</p>
@else
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="border-b border-line bg-surface-2 text-left text-xs font-semibold text-muted">
<th class="px-4 py-3 font-semibold">{{ __('security.col_ip') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('security.col_blocked') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('security.col_status') }}</th>
</tr>
</thead>
<tbody>
@foreach ($history as $block)
<tr wire:key="history-{{ $block->uuid }}" class="border-b border-line last:border-0">
<td class="px-4 py-3 font-mono text-body">{{ $block->ip }}</td>
<td class="px-4 py-3 font-mono text-xs text-muted">{{ $block->blocked_at->local()->isoFormat('DD.MM. HH:mm') }}</td>
<td class="px-4 py-3">
@if ($block->released_at)
<x-ui.badge status="active">{{ __('security.status_released') }}</x-ui.badge>
@else
<x-ui.badge status="info">{{ __('security.status_expired') }}</x-ui.badge>
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
</div>

View File

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

View File

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

View File

@ -0,0 +1,82 @@
<?php
use App\Mail\InvoiceMail;
use App\Mail\ResetPasswordMail;
use App\Models\Customer;
use App\Models\Invoice;
use App\Models\Order;
use App\Models\User;
use App\Services\Billing\IssueInvoice;
use App\Services\Mail\MailLane;
use App\Support\CompanyProfile;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
/**
* Die Spur entsteht beim Einreihen, nicht beim Senden.
*
* Der Arbeiter liest die Schlangen in der Reihenfolge ihrer Dringlichkeit
* deshalb steht ein Kennwort-Zurücksetzen nie hinter zweihundert Rechnungen.
* Das trägt nur, wenn die Rechnung wirklich in einer anderen Schlange liegt.
*
* Werkzeug: Queue::fake() statt Mail::fake(). Mail::fake() ersetzt den
* ganzen Mailer der Aufruf, der onQueue() setzt (Mailer::queue(), der
* dann Mailable::queue() aufruft), fände dann nie statt, und der Test würde
* grün, ohne die Spur je berechnet zu haben. Queue::fake() lässt genau
* diesen Aufruf laufen und fängt erst dahinter ab, an der Queue-Fabrik
* die Stelle, an der pushOn() tatsächlich ankommt.
*/
function invoiceForTest(): Invoice
{
CompanyProfile::put([
'name' => '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([]);
});

View File

@ -0,0 +1,129 @@
<?php
use App\Livewire\Admin\ConfirmReleaseBlock;
use App\Livewire\Admin\CustomerDetail;
use App\Livewire\Admin\HostDetail;
use App\Livewire\Admin\Overview;
use App\Models\Customer;
use App\Models\Host;
use App\Models\Instance;
use App\Models\Operator;
use App\Models\SecurityBlock;
use Livewire\Livewire;
use Spatie\Permission\Models\Role;
/**
* Task 6, Schritt 4: dieselben Sperr-Zeilen in der Konsole, hinter der
* jeweiligen Berechtigung hosts.manage auf der Host-Detailseite,
* instances.manage auf der Kundenseite (Instanz eines Kunden). Beide teilen
* sich EIN Modal (App\Livewire\Admin\ConfirmReleaseBlock), das nach dem
* Subjekt der Sperre autorisiert.
*/
it('zeigt einem Betreiber mit hosts.manage die Sperre eines Hosts und laesst ihn sie aufheben', function () {
$host = Host::factory()->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]));
});

View File

@ -0,0 +1,60 @@
<?php // tests/Feature/Security/SecurityPageTest.php
use App\Livewire\Security;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\SecurityBlock;
use Livewire\Livewire;
// Abweichung vom Auftragszettel: Customer::factory()->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);
});