From 0e3a76a7cbca8fbe820cca9513350fc02bfff523 Mon Sep 17 00:00:00 2001 From: nexxo Date: Mon, 3 Aug 2026 21:52:59 +0200 Subject: [PATCH] Aus der Attrappe wird Verwaltung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anlegen und Einladen sind zwei Vorgaenge. Einladen schickt einen Auftrag auf die Bereitstellungs-Warteschlange — die einzige, die einen Gast erreicht — und der Sitz zeigt danach, was WIRKLICH passiert ist, samt Grund und Wiederholen-Knopf. Ohne das drueckt der Inhaber wieder und wieder. Entziehen loescht nichts mehr. Ratelimit 10 je Kunde und 3 je Sitz pro Stunde, mit echter Restzeit in der Meldung statt stummer Verweigerung. Eine Pruefung verbietet user:delete im ganzen app/-Verzeichnis. --- app/Livewire/Users.php | 251 +++++++++++++++--- app/Provisioning/Jobs/SyncSeatToNextcloud.php | 101 +++++++ lang/de/users.php | 24 +- lang/en/users.php | 23 +- resources/views/livewire/users.blade.php | 60 ++++- .../Feature/Seats/SyncSeatToNextcloudTest.php | 248 +++++++++++++++++ tests/Feature/SeatsTest.php | 37 ++- 7 files changed, 686 insertions(+), 58 deletions(-) create mode 100644 app/Provisioning/Jobs/SyncSeatToNextcloud.php create mode 100644 tests/Feature/Seats/SyncSeatToNextcloudTest.php diff --git a/app/Livewire/Users.php b/app/Livewire/Users.php index 8e14415..62ad910 100644 --- a/app/Livewire/Users.php +++ b/app/Livewire/Users.php @@ -5,8 +5,10 @@ namespace App\Livewire; use App\Livewire\Concerns\ResolvesCustomer; use App\Models\Customer; use App\Models\Seat; +use App\Provisioning\Jobs\SyncSeatToNextcloud; use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\RateLimiter; use Livewire\Attributes\Layout; use Livewire\Attributes\On; use Livewire\Attributes\Validate; @@ -26,14 +28,18 @@ class Users extends Component #[Validate('required|in:admin,member,readonly')] public string $inviteRole = 'member'; - public function mount(): void { // Every customer starts with themselves as the owner seat. firstOrCreate // keyed on (customer_id, email) is idempotent; the catch covers the // concurrent-first-visit race against the unique index. $customer = $this->customer(); - if ($customer !== null && $customer->seats()->count() === 0) { + + if ($customer === null) { + return; + } + + if ($customer->seats()->count() === 0) { try { $customer->seats()->firstOrCreate( ['email' => $customer->email], @@ -43,9 +49,40 @@ class Users extends Component // Another concurrent first visit created it — fine. } } + + /* + * Der Inhaber-Sitz wird mit dem bestehenden Admin-Konto verknuepft. + * + * Dieses Konto gibt es in der Nextcloud laengst — CreateCustomerAdmin + * hat es beim Aufbau angelegt. Ohne diese Verknuepfung stuende der Sitz + * auf `none`, und das Panel boete dem Inhaber an, sich SELBST + * einzuladen; der Auftrag traefe dann auf einen Benutzer, den es schon + * gibt. + * + * Bei JEDEM Besuch versucht, nicht nur beim allerersten: wer das Panel + * oeffnet, bevor die Bereitstellung das Admin-Konto angelegt hat, + * behielte sonst fuer immer einen owner-Sitz auf `none`. Der Aufruf ist + * folgenlos, solange es kein Konto gibt, und die Bedingung davor haelt + * ihn von jedem Sitz fern, der schon verknuepft ist. + * + * Die Wanderung aus Aufgabe 5 tut dasselbe fuer den Bestand — sie fuehrt + * ihre eigene, eingefrorene Fassung. Hier gilt die laufende. + */ + $owner = $customer->seats()->where('role', 'owner')->first(); + + if ($owner !== null && $owner->nc_state === Seat::STATE_NONE) { + $owner->linkToInstanceAdmin(); + } } - public function invite(): void + /** + * Anlegen — der erste der beiden Vorgaenge. + * + * Schickt ausdruecklich NICHTS los und laesst `nc_state` auf `none`: ein + * Inhaber soll sein Team vorbereiten koennen, ohne dass jemand eine Mail + * bekommt. Erst `sendInvite()` erzeugt einen Benutzer im Gast. + */ + public function addSeat(): void { $customer = $this->requireCustomer(); if ($customer === null) { @@ -90,7 +127,144 @@ class Users extends Component $this->reset('inviteEmail', 'inviteName', 'inviteRole'); $this->inviteRole = 'member'; - $this->dispatch('notify', message: __('users.invited')); + $this->dispatch('notify', message: __('users.added')); + } + + /** + * Einladen — der zweite, getrennte Schritt. + * + * Anlegen und Einladen sind ausdruecklich zwei Vorgaenge: ein Inhaber + * soll sein Team vorbereiten und die Einladungen spaeter verschicken + * koennen, etwa alle am ersten Arbeitstag. + */ + public function sendInvite(string $uuid): void + { + $customer = $this->requireCustomer(); + + if ($customer === null) { + return; + } + + $seat = $customer->seats()->where('uuid', $uuid)->first(); + + if ($seat === null) { + return; + } + + if (($warten = $this->rateLimited($customer, $seat)) !== null) { + $this->dispatch('notify', message: __('users.too_many_invites', ['minutes' => $warten])); + + return; + } + + // Einmal gesetzt, nie wieder geaendert: Nextcloud kann Benutzer nicht + // umbenennen. Ein Sitz, dessen Adresse sich spaeter aendert, behaelt + // seinen Anmeldenamen. + if (blank($seat->nc_username)) { + $seat->nc_username = $seat->email; + } + + $seat->forceFill([ + 'nc_username' => $seat->nc_username, + 'status' => 'invited', + 'invited_at' => now(), + 'nc_state' => Seat::STATE_PENDING, + 'nc_error' => null, + ])->save(); + + SyncSeatToNextcloud::dispatch($seat->uuid, 'invite'); + + $this->dispatch('notify', message: __('users.invite_sent')); + } + + /** + * Der zweite Versuch nach einem Fehlschlag. + * + * Ohne ihn bliebe dem Inhaber bei einem Gast, der einmal nicht erreichbar + * war, nur die Zeile zu loeschen und neu anzulegen — also genau der + * Datenverlust, den `revoke()` gerade abgeschafft hat. + * + * Kein Ratelimit: der Knopf erscheint nur an einem fehlgeschlagenen Sitz + * und setzt ihn sofort auf `pending`, wo er keinen Knopf mehr hat. Er kann + * also gar nicht schneller gedrueckt werden, als die Warteschlange + * antwortet — und ihn zu drosseln hiesse, die Rueckfahrkarte aus einem + * Fehlschlag zu drosseln. + */ + public function retry(string $uuid): void + { + $customer = $this->requireCustomer(); + + if ($customer === null) { + return; + } + + $seat = $customer->seats()->where('uuid', $uuid)->first(); + + if ($seat === null || $seat->nc_state !== Seat::STATE_FAILED) { + return; + } + + // Welcher Auftrag der richtige ist, steht am Sitz selbst: was der + // Inhaber WILL (`status`) und ob dieser Sitz je in der Nextcloud + // ankam (`nc_synced_at`). Den letzten Auftrag mitzuschreiben waere ein + // Feld, das nach dem ersten Erfolg nie wieder stimmt. + $action = match (true) { + in_array($seat->status, ['revoked', 'suspended'], true) => 'disable', + $seat->nc_synced_at === null => 'invite', + default => 'role', + }; + + $seat->forceFill(['nc_state' => Seat::STATE_PENDING, 'nc_error' => null])->save(); + SyncSeatToNextcloud::dispatch($seat->uuid, $action); + + $this->dispatch('notify', message: __('users.retrying')); + } + + /** + * Zwei Grenzen, beide aus dem Betrieb heraus gefordert: eine je Kunde + * gegen den Rundumschlag, eine je Sitz gegen das wiederholte Draufdruecken + * an derselben Zeile. + * + * Gibt die Restzeit in Minuten zurueck, oder null wenn frei. Eine stumme + * Verweigerung waere schlimmer als die Grenze selbst. + */ + private function rateLimited(Customer $customer, Seat $seat): ?int + { + foreach ([ + ['seat-invite:customer:'.$customer->id, 10], + ['seat-invite:seat:'.$seat->id, 3], + ] as [$schluessel, $grenze]) { + if (RateLimiter::tooManyAttempts($schluessel, $grenze)) { + return (int) ceil(RateLimiter::availableIn($schluessel) / 60); + } + } + + RateLimiter::increment('seat-invite:customer:'.$customer->id, 3600); + RateLimiter::increment('seat-invite:seat:'.$seat->id, 3600); + + return null; + } + + /** + * Ein Sitz, der nie in der Nextcloud war, braucht keinen Auftrag — es + * gaebe dort nichts zu aendern, und der Fehlschlag stellte die Zeile + * danach auf „fehlgeschlagen", also auf eine Fehlermeldung fuer etwas, + * das nie ein Fehler war. + * + * Geprueft werden BEIDE Angaben: ein Sitz, der schon einen Anmeldenamen + * traegt, kann dort ein Konto haben, auch wenn `nc_state` es (noch) nicht + * sagt. Von den beiden moeglichen Irrtuemern ist ein ueberfluessiger + * Auftrag der harmlose — der andere hiesse, dass ein entzogener Zugang + * offen bleibt. + */ + private function queueSync(Seat $seat, string $action): void + { + if ($seat->nc_state === Seat::STATE_NONE && blank($seat->nc_username)) { + return; + } + + $seat->forceFill(['nc_state' => Seat::STATE_PENDING, 'nc_error' => null])->save(); + SyncSeatToNextcloud::dispatch($seat->uuid, $action); } public function setRole(string $uuid, string $role): void @@ -104,22 +278,30 @@ class Users extends Component } // Lock the customer so a concurrent owner change can't race past the guard. - $ok = DB::transaction(function () use ($customer, $uuid, $role) { + $seat = DB::transaction(function () use ($customer, $uuid, $role) { Customer::query()->whereKey($customer->id)->lockForUpdate()->first(); $seat = $customer->seats()->where('uuid', $uuid)->first(); if ($seat === null) { - return true; + return null; } if ($seat->role === 'owner' && $role !== 'owner' && $customer->seats()->where('role', 'owner')->count() <= 1) { return false; // would remove the last owner } $seat->update(['role' => $role]); - return true; + return $seat; }); - if (! $ok) { + if ($seat === false) { $this->dispatch('notify', message: __('users.last_owner')); + + return; + } + + // Erst nach dem Commit: ein Auftrag, den die Warteschlange schneller + // aufnimmt als die Transaktion schliesst, liest die alte Rolle. + if ($seat instanceof Seat) { + $this->queueSync($seat, 'role'); } } @@ -150,6 +332,11 @@ class Users extends Component $seat->update(['status' => $seat->status === 'suspended' ? 'active' : 'suspended']); + // Der Klick allein sperrt niemanden aus: bis der Auftrag durch ist, + // arbeitet der Gesperrte weiter. Deshalb faehrt die Sperre in den Gast + // und nicht nur in die Statusspalte. + $this->queueSync($seat, $seat->status === 'suspended' ? 'disable' : 'enable'); + $this->dispatch('notify', message: __( $seat->status === 'suspended' ? 'users.suspended' : 'users.reactivated', )); @@ -162,23 +349,33 @@ class Users extends Component return; } - $result = DB::transaction(function () use ($customer, $uuid) { + $seat = DB::transaction(function () use ($customer, $uuid) { Customer::query()->whereKey($customer->id)->lockForUpdate()->first(); $seat = $customer->seats()->where('uuid', $uuid)->first(); if ($seat === null) { - return 'gone'; + return null; } if ($seat->role === 'owner' && $customer->seats()->where('role', 'owner')->count() <= 1) { - return 'last_owner'; + return false; } - $seat->delete(); - return 'ok'; + // Nicht loeschen. Der Zugang ist zu, die Arbeit bleibt dort, wo + // sein Team sie braucht. Wer wirklich loeschen will, tut das in + // der Nextcloud, wo Nextcloud danach fragt, was mit den Dateien + // geschehen soll. + $seat->update(['status' => 'revoked']); + + return $seat; }); - if ($result === 'last_owner') { + if ($seat === false) { $this->dispatch('notify', message: __('users.last_owner')); - } elseif ($result === 'ok') { + + return; + } + + if ($seat instanceof Seat) { + $this->queueSync($seat, 'disable'); $this->dispatch('notify', message: __('users.revoked')); } } @@ -193,22 +390,6 @@ class Users extends Component $this->revoke($uuid); } - public function resend(string $uuid): void - { - // Invite delivery is mocked for now. - if ($this->seat($uuid) !== null) { - $this->dispatch('notify', message: __('users.resent')); - } - } - - - private function ownerCount(): int - { - $customer = $this->customer(); - - return $customer ? $customer->seats()->where('role', 'owner')->count() : 0; - } - private function usedSeats(Customer $customer): int { return $customer->seats()->where('status', '!=', 'revoked')->count(); @@ -220,19 +401,13 @@ class Users extends Component // failed/deprovisioned record. $instance = $customer->instances()->whereIn('status', ['active', 'cancellation_scheduled'])->latest('id')->first() ?? $customer->instances()->latest('id')->first(); + // From the contract: how many people a customer may invite is part of // what they bought. Cutting a plan's seats in the catalogue must not // lock users out of an existing customer's cloud. return (int) ($instance?->subscription?->seats ?? 5); } - private function seat(string $uuid): ?Seat - { - $customer = $this->customer(); - - return $customer?->seats()->where('uuid', $uuid)->first(); - } - public function render() { $customer = $this->customer(); diff --git a/app/Provisioning/Jobs/SyncSeatToNextcloud.php b/app/Provisioning/Jobs/SyncSeatToNextcloud.php new file mode 100644 index 0000000..96ef576 --- /dev/null +++ b/app/Provisioning/Jobs/SyncSeatToNextcloud.php @@ -0,0 +1,101 @@ +onConnection('provisioning'); + $this->onQueue('provisioning'); + } + + /** Auch was an handle() vorbeifliegt, muss am Sitz sichtbar werden. */ + public function failed(?Throwable $e): void + { + Seat::query()->where('uuid', $this->seatUuid)->update([ + 'nc_state' => Seat::STATE_FAILED, + 'nc_error' => 'unexpected', + ]); + } + + public function handle(NextcloudUsers $users): void + { + $seat = Seat::query()->with('customer')->where('uuid', $this->seatUuid)->first(); + + if ($seat === null) { + return; + } + + // Erneut geprueft, nicht der Seite geglaubt: eine geschlossene Instanz + // kann ihre VMID auf demselben Host weiterverliehen haben — dieselbe + // Falle, die in IssueInstanceAdminAccess schon beschrieben steht. + $instance = $seat->customer?->instances() + ->whereIn('status', ['active', 'cancellation_scheduled']) + ->latest('id')->first(); + + if ($instance === null || $instance->host === null || blank($instance->vmid)) { + $this->record($seat, false, 'no_instance'); + + return; + } + + $ok = match ($this->action) { + // Einladen setzt in einem Zug auch die Gruppe: ein Benutzer, der + // angelegt ist und in keiner Rolle steckt, sieht in seiner neuen + // Cloud nichts und meldet sich am ersten Tag beim Inhaber. + 'invite' => $users->invite($instance, $seat) && $users->applyRole($instance, $seat), + 'role' => $users->applyRole($instance, $seat), + 'disable' => $users->disable($instance, $seat), + 'enable' => $users->enable($instance, $seat), + default => false, + }; + + $this->record($seat, $ok, $ok ? null : 'guest_failed'); + } + + /** + * `nc_synced_at` bleibt beim Fehlschlag stehen, statt geleert zu werden: + * wann dieser Sitz zuletzt WIRKLICH stimmte, ist genau die Angabe, die + * einen Fehlschlag einordnet — heute Morgen oder vor drei Wochen. + */ + private function record(Seat $seat, bool $ok, ?string $grund): void + { + $seat->forceFill([ + 'nc_state' => $ok ? Seat::STATE_SYNCED : Seat::STATE_FAILED, + 'nc_error' => $grund, + 'nc_synced_at' => $ok ? now() : $seat->nc_synced_at, + ])->save(); + } +} diff --git a/lang/de/users.php b/lang/de/users.php index f0719f0..8834822 100644 --- a/lang/de/users.php +++ b/lang/de/users.php @@ -8,8 +8,14 @@ return [ 'invite_email' => 'E-Mail', 'invite_name' => 'Name (optional)', + // Zwei Knöpfe, zwei Wörter: 'add' legt die Zeile an und verschickt nichts, + // 'invite' ist der Knopf in der Tabelle, der es wirklich tut. Ein + // Anlegen-Knopf namens „Einladen" wäre genau die Attrappe, die hier + // abgeschafft wurde. + 'add' => 'Hinzufügen', 'invite' => 'Einladen', 'role' => 'Rolle', + 'role_readonly_means' => 'Kann nichts hochladen oder anlegen. Was ihm freigegeben wird, kann er im Rahmen der Freigabe bearbeiten.', 'role_owner' => 'Inhaber', 'role_admin' => 'Administrator', @@ -60,10 +66,24 @@ return [ 'resend' => 'Erneut senden', 'revoke' => 'Entfernen', - 'invited' => 'Einladung gesendet.', + // Was in der Nextcloud WIRKLICH ist — neben dem, was der Inhaber will. + // Ohne diese Zeilen wirkt jeder Knopf folgenlos, weil alles über die + // Warteschlange läuft. + 'state_none' => 'angelegt — noch nicht eingeladen', + 'state_pending' => 'wird eingerichtet …', + 'state_failed' => 'fehlgeschlagen', + 'retry' => 'Nochmal versuchen', + 'retrying' => 'Wird noch einmal versucht.', + 'error_no_instance' => 'Die Cloud dieses Kunden ist gerade nicht erreichbar.', + 'error_guest_failed' => 'Die Cloud hat die Änderung nicht angenommen.', + 'error_unexpected' => 'Unerwarteter Fehler. Bitte noch einmal versuchen.', + + 'added' => 'Benutzer angelegt. Die Einladung verschicken Sie mit „Einladen".', + 'invite_sent' => 'Einladung verschickt. Der Mitarbeiter bekommt einen Link, an dem er sein Passwort selbst setzt.', + 'too_many_invites' => 'Zu viele Einladungen — in :minutes Minuten wieder möglich.', 'revoked' => 'Benutzer entfernt.', 'resent' => 'Einladung erneut gesendet.', 'last_owner' => 'Der letzte Inhaber kann nicht entfernt oder geändert werden.', 'limit_reached' => 'Platz-Limit Ihres Pakets erreicht. Bitte upgraden.', - 'duplicate' => 'Diese E-Mail ist bereits eingeladen.', + 'duplicate' => 'Für diese E-Mail gibt es bereits einen Platz.', ]; diff --git a/lang/en/users.php b/lang/en/users.php index 7ddc885..61cff87 100644 --- a/lang/en/users.php +++ b/lang/en/users.php @@ -8,8 +8,13 @@ return [ 'invite_email' => 'Email', 'invite_name' => 'Name (optional)', + // Two buttons, two words: 'add' creates the row and sends nothing, 'invite' + // is the button in the table that really does it. An add button labelled + // "Invite" would be exactly the pretence this change removed. + 'add' => 'Add', 'invite' => 'Invite', 'role' => 'Role', + 'role_readonly_means' => 'Cannot upload or create anything. What is shared with them, they can edit within that share.', 'role_owner' => 'Owner', 'role_admin' => 'Administrator', @@ -60,10 +65,24 @@ return [ 'resend' => 'Resend', 'revoke' => 'Remove', - 'invited' => 'Invitation sent.', + // What is REALLY true in the Nextcloud — next to what the owner wants. + // Without these lines every button looks like it did nothing, because all + // of it runs through the queue. + 'state_none' => 'added — not invited yet', + 'state_pending' => 'being set up …', + 'state_failed' => 'failed', + 'retry' => 'Try again', + 'retrying' => 'Trying again.', + 'error_no_instance' => 'This customer\'s cloud cannot be reached right now.', + 'error_guest_failed' => 'The cloud did not accept the change.', + 'error_unexpected' => 'Unexpected error. Please try again.', + + 'added' => 'User added. Send the invitation with "Invite".', + 'invite_sent' => 'Invitation sent. They get a link to set their own password.', + 'too_many_invites' => 'Too many invitations — possible again in :minutes minutes.', 'revoked' => 'User removed.', 'resent' => 'Invitation resent.', 'last_owner' => 'The last owner cannot be removed or changed.', 'limit_reached' => 'Your plan seat limit is reached. Please upgrade.', - 'duplicate' => 'This email is already invited.', + 'duplicate' => 'This email already has a seat.', ]; diff --git a/resources/views/livewire/users.blade.php b/resources/views/livewire/users.blade.php index 329c578..d243ede 100644 --- a/resources/views/livewire/users.blade.php +++ b/resources/views/livewire/users.blade.php @@ -11,8 +11,10 @@ - {{-- Invite --}} -
+ {{-- Anlegen. Ausdruecklich NICHT einladen: hier entsteht nur die Zeile, + die Einladung verschickt der Knopf in der Tabelle. Ein Inhaber soll + sein Team vorbereiten koennen, ohne dass jemand eine Mail bekommt. --}} +
@@ -29,9 +31,12 @@ + {{-- Der ehrliche Satz zu „Nur Lesen": die Rolle nimmt den eigenen + Speicherplatz weg, hebt aber eine Freigabe nicht auf. --}} +

{{ __('users.role_readonly_means') }}

- - {{ __('users.invite') }} + + {{ __('users.add') }} @@ -65,14 +70,53 @@ @endif + {{-- Was der Inhaber WILL (`status`) und was in der + Nextcloud WIRKLICH ist (`nc_state`) stehen in + einer Zelle, und der wahre Zustand geht vor: + solange der Auftrag laeuft oder gescheitert + ist, waere „Eingeladen" eine Behauptung ueber + etwas, das die Warteschlange noch gar nicht + erledigt hat. Ohne diese Spalte drueckt der + Inhaber wieder und wieder, weil nichts + sichtbar geschieht. --}} - @php $sb = ['active' => 'active', 'invited' => 'provisioning', 'revoked' => 'suspended'][$seat->status] ?? 'info'; @endphp - {{ __('users.status_'.$seat->status) }} + @if ($seat->nc_state === \App\Models\Seat::STATE_NONE) + {{ __('users.state_none') }} + @elseif ($seat->nc_state === \App\Models\Seat::STATE_PENDING) + {{ __('users.state_pending') }} + @elseif ($seat->nc_state === \App\Models\Seat::STATE_FAILED) + {{ __('users.state_failed') }} + {{-- Der Grund im Klartext, nicht der Code: + 'guest_failed' sagt einem Inhaber + nichts, und ein Fehlschlag ohne Grund + ist eine Sackgasse. --}} +

{{ __('users.error_'.($seat->nc_error ?: 'unexpected')) }}

+ + @else + @php $sb = ['active' => 'active', 'invited' => 'provisioning', 'revoked' => 'suspended'][$seat->status] ?? 'info'; @endphp + {{ __('users.status_'.$seat->status) }} + @endif
- @if ($seat->status === 'invited') - + {{-- Einladen ist der zweite Vorgang und + hat deshalb seinen eigenen Knopf. Er + steht nur an einer Zeile, die noch + nichts in der Nextcloud hat; ist der + Auftrag unterwegs, gibt es nichts zu + druecken, und bei einem Fehlschlag + steht „Nochmal versuchen" schon in der + Zustandsspalte. --}} + @if ($seat->nc_state === \App\Models\Seat::STATE_NONE) + + @elseif ($seat->nc_state === \App\Models\Seat::STATE_SYNCED && $seat->status === 'invited') + @endif {{-- Rename is offered on every row, the owner diff --git a/tests/Feature/Seats/SyncSeatToNextcloudTest.php b/tests/Feature/Seats/SyncSeatToNextcloudTest.php new file mode 100644 index 0000000..635105c --- /dev/null +++ b/tests/Feature/Seats/SyncSeatToNextcloudTest.php @@ -0,0 +1,248 @@ +instance(ProxmoxClient::class, new FakeProxmoxClient); + $customer = Customer::factory()->create(); + // host_id, weil die host-Beziehung sonst null bliebe: der Auftrag bricht + // dann mit 'no_instance' ab und käme nie bis zum Gast — dieselbe Falle wie + // in NextcloudUsersTest::gastBereit(). + Instance::factory()->for($customer)->create(['status' => 'active', 'vmid' => 201, 'host_id' => Host::factory()]); + $sitz = Seat::factory()->for($customer)->create([ + 'email' => 'anna@firma.tld', 'nc_username' => 'anna@firma.tld', + 'nc_state' => Seat::STATE_PENDING, + ]); + + (new SyncSeatToNextcloud($sitz->uuid, 'invite'))->handle(app(NextcloudUsers::class)); + + expect($sitz->fresh()->nc_state)->toBe(Seat::STATE_SYNCED) + ->and($sitz->fresh()->nc_error)->toBeNull() + ->and($sitz->fresh()->nc_synced_at)->not->toBeNull(); +}); + +it('schreibt den Fehlschlag mit Grund an den Sitz', function () { + $pve = new FakeProxmoxClient; + $pve->guestThrows[201] = new RuntimeException('guest agent unreachable'); + app()->instance(ProxmoxClient::class, $pve); + $customer = Customer::factory()->create(); + Instance::factory()->for($customer)->create(['status' => 'active', 'vmid' => 201, 'host_id' => Host::factory()]); + $sitz = Seat::factory()->for($customer)->create(['nc_username' => 'anna@firma.tld']); + + (new SyncSeatToNextcloud($sitz->uuid, 'invite'))->handle(app(NextcloudUsers::class)); + + // Nicht nur „irgendein Fehler": der Grund muss der des unerreichbaren + // Gastes sein, nicht der der fehlenden Instanz — sonst liefe der Test auch + // dann grün, wenn die Instanz gar nicht gefunden wurde. + expect($sitz->fresh()->nc_state)->toBe(Seat::STATE_FAILED) + ->and($sitz->fresh()->nc_error)->toBe('guest_failed'); +}); + +it('sagt es am Sitz, wenn es gar keine erreichbare Instanz gibt', function () { + // Der Kunde hat keine laufende Instanz — es gibt nichts zu spiegeln, und + // der Inhaber muss den Unterschied zu „der Gast hat abgelehnt" lesen + // können, weil nur das eine von beiden er selbst beheben kann. + $customer = Customer::factory()->create(); + $sitz = Seat::factory()->for($customer)->create(['nc_username' => 'anna@firma.tld']); + + (new SyncSeatToNextcloud($sitz->uuid, 'invite'))->handle(app(NextcloudUsers::class)); + + expect($sitz->fresh()->nc_state)->toBe(Seat::STATE_FAILED) + ->and($sitz->fresh()->nc_error)->toBe('no_instance'); +}); + +it('laeuft auf der Bereitstellungs-Warteschlange, nirgends sonst', function () { + // Nur dieser Arbeiter hat Tunnel und Proxmox-Zugangsdaten. Auf der + // Standard-Warteschlange erreicht der Auftrag keinen einzigen Gast. + $auftrag = new SyncSeatToNextcloud('egal', 'invite'); + + expect($auftrag->connection)->toBe('provisioning') + ->and($auftrag->queue)->toBe('provisioning'); +}); + +it('legt beim Anlegen KEINEN Nextcloud-Benutzer an', function () { + // Anlegen ist nicht Einladen. Der Inhaber soll sein Team vorbereiten + // koennen, ohne dass jemand eine Mail bekommt. + Queue::fake(); + $customer = Customer::factory()->create(); + $user = $customer->ensureUser(); + Instance::factory()->for($customer)->create(['status' => 'active']); + + Livewire::actingAs($user)->test(Users::class) + ->set('inviteEmail', 'anna@firma.tld') + ->set('inviteName', 'Anna') + ->call('addSeat'); + + Queue::assertNothingPushed(); + expect(Seat::where('email', 'anna@firma.tld')->first()->nc_state)->toBe(Seat::STATE_NONE); +}); + +it('schickt erst beim Einladen einen Auftrag los', function () { + Queue::fake(); + $customer = Customer::factory()->create(); + $user = $customer->ensureUser(); + Instance::factory()->for($customer)->create(['status' => 'active']); + $sitz = Seat::factory()->for($customer)->create(['role' => 'member']); + + Livewire::actingAs($user)->test(Users::class)->call('sendInvite', $sitz->uuid); + + Queue::assertPushed(SyncSeatToNextcloud::class); + expect($sitz->fresh()->nc_state)->toBe(Seat::STATE_PENDING) + ->and($sitz->fresh()->nc_username)->toBe($sitz->email); +}); + +it('haelt den Benutzernamen fest, auch wenn die Mailadresse sich aendert', function () { + // Nextcloud kann Benutzer nicht umbenennen. Ein Sitz, dessen Adresse sich + // spaeter aendert, behaelt seinen Anmeldenamen. + Queue::fake(); + $customer = Customer::factory()->create(); + $user = $customer->ensureUser(); + Instance::factory()->for($customer)->create(['status' => 'active']); + $sitz = Seat::factory()->for($customer)->create(['email' => 'alt@firma.tld', 'nc_username' => 'alt@firma.tld']); + + $sitz->update(['email' => 'neu@firma.tld']); + Livewire::actingAs($user)->test(Users::class)->call('sendInvite', $sitz->uuid); + + expect($sitz->fresh()->nc_username)->toBe('alt@firma.tld'); +}); + +it('weist die elfte Einladung derselben Stunde ab und sagt die Restzeit', function () { + Queue::fake(); + $customer = Customer::factory()->create(); + $user = $customer->ensureUser(); + Instance::factory()->for($customer)->create(['status' => 'active']); + $sitze = Seat::factory()->for($customer)->count(11)->create(['role' => 'member']); + + $seite = Livewire::actingAs($user)->test(Users::class); + + foreach ($sitze->take(10) as $sitz) { + $seite->call('sendInvite', $sitz->uuid); + } + + $seite->call('sendInvite', $sitze->last()->uuid); + + // Keine stumme Verweigerung: die Meldung nennt die echte Restzeit. Auf die + // Zahl geprüft statt auf den Satz — ein Test, der denselben __()-Aufruf + // nachrechnet wie die Implementierung, würde auch eine fehlende Restzeit + // durchwinken (siehe R19). + $seite->assertDispatched( + 'notify', + fn ($ereignis, $daten) => str_contains($daten['message'], '60'), + ); + expect($sitze->last()->fresh()->nc_state)->toBe(Seat::STATE_NONE); + Queue::assertPushed(SyncSeatToNextcloud::class, 10); +}); + +it('weist das vierte Draufdruecken auf denselben Sitz ab', function () { + // Die zweite Grenze: gegen das wiederholte Klicken an EINER Zeile, das + // demselben Menschen sonst vier Willkommensmails schickt. + Queue::fake(); + $customer = Customer::factory()->create(); + $user = $customer->ensureUser(); + Instance::factory()->for($customer)->create(['status' => 'active']); + $sitz = Seat::factory()->for($customer)->create(['role' => 'member']); + + $seite = Livewire::actingAs($user)->test(Users::class); + + foreach (range(1, 4) as $ignoriert) { + $seite->call('sendInvite', $sitz->uuid); + } + + Queue::assertPushed(SyncSeatToNextcloud::class, 3); +}); + +it('entzieht, ohne die Zeile zu loeschen', function () { + Queue::fake(); + $customer = Customer::factory()->create(); + $user = $customer->ensureUser(); + Instance::factory()->for($customer)->create(['status' => 'active']); + $sitz = Seat::factory()->for($customer)->create(['role' => 'member', 'nc_username' => 'anna@firma.tld']); + + Livewire::actingAs($user)->test(Users::class)->call('revoke', $sitz->uuid); + + // Der Datensatz bleibt. Ein Fehlgriff im Userpanel darf die Arbeit eines + // Menschen nicht vernichten — und in einem Produkt, das mit + // Nachvollziehbarkeit verkauft wird, gaebe es danach nichts mehr zu zeigen. + expect(Seat::find($sitz->id))->not->toBeNull() + ->and($sitz->fresh()->status)->toBe('revoked'); + Queue::assertPushed(SyncSeatToNextcloud::class); +}); + +it('schickt fuer einen Sitz, der nie in der Nextcloud war, keinen Auftrag', function () { + // Angelegt, nie eingeladen: es gäbe dort nichts zu sperren. Ein Auftrag + // liefe ins Leere und stellte die Zeile danach auf „fehlgeschlagen" — + // eine Fehlermeldung für etwas, das nie ein Fehler war. + Queue::fake(); + $customer = Customer::factory()->create(); + $user = $customer->ensureUser(); + Instance::factory()->for($customer)->create(['status' => 'active']); + $sitz = Seat::factory()->for($customer)->create(['role' => 'member']); + + Livewire::actingAs($user)->test(Users::class)->call('revoke', $sitz->uuid); + + Queue::assertNothingPushed(); + expect($sitz->fresh()->status)->toBe('revoked') + ->and($sitz->fresh()->nc_state)->toBe(Seat::STATE_NONE); +}); + +it('spiegelt eine Rollenaenderung in die Nextcloud', function () { + Queue::fake(); + $customer = Customer::factory()->create(); + $user = $customer->ensureUser(); + Instance::factory()->for($customer)->create(['status' => 'active']); + $sitz = Seat::factory()->for($customer)->create([ + 'role' => 'member', 'nc_username' => 'anna@firma.tld', 'nc_state' => Seat::STATE_SYNCED, + ]); + + Livewire::actingAs($user)->test(Users::class)->call('setRole', $sitz->uuid, 'readonly'); + + Queue::assertPushed(SyncSeatToNextcloud::class, fn ($auftrag) => $auftrag->action === 'role'); + expect($sitz->fresh()->nc_state)->toBe(Seat::STATE_PENDING); +}); + +it('gibt dem Inhaber nach einem Fehlschlag einen zweiten Versuch', function () { + Queue::fake(); + $customer = Customer::factory()->create(); + $user = $customer->ensureUser(); + Instance::factory()->for($customer)->create(['status' => 'active']); + $sitz = Seat::factory()->for($customer)->create([ + 'role' => 'member', 'nc_username' => 'anna@firma.tld', + 'nc_state' => Seat::STATE_FAILED, 'nc_error' => 'guest_failed', + ]); + + Livewire::actingAs($user)->test(Users::class)->call('retry', $sitz->uuid); + + // Der Grund verschwindet mit dem neuen Versuch — ein stehengebliebener + // alter Fehlertext neben „wird eingerichtet …" wäre die Attrappe zurück. + Queue::assertPushed(SyncSeatToNextcloud::class, fn ($auftrag) => $auftrag->action === 'invite'); + expect($sitz->fresh()->nc_state)->toBe(Seat::STATE_PENDING) + ->and($sitz->fresh()->nc_error)->toBeNull(); +}); + +it('nennt in keiner Datei unter app/ das Loeschen eines Benutzers', function () { + // Testerzwungene Regel: kein Nextcloud-Benutzer wird je geloescht, und + // keine Datei. Wer das aendern will, muss diese Pruefung anfassen und + // dabei ueber die Folgen stolpern. + $treffer = []; + + foreach (Finder::create()->files()->in(app_path())->name('*.php') as $datei) { + if (str_contains($datei->getContents(), 'user:delete')) { + $treffer[] = $datei->getRelativePathname(); + } + } + + expect($treffer)->toBe([]); +}); diff --git a/tests/Feature/SeatsTest.php b/tests/Feature/SeatsTest.php index fb43417..d93ae0a 100644 --- a/tests/Feature/SeatsTest.php +++ b/tests/Feature/SeatsTest.php @@ -29,16 +29,21 @@ it('creates an owner seat on first visit', function () { expect($owner)->not->toBeNull()->and($owner->email)->toBe('o@seat.test'); }); -it('invites a seat and blocks duplicates', function () { +// invite() heisst seit dem Nextcloud-Anschluss addSeat(), weil sie genau das +// tut und nichts weiter: eine Zeile anlegen. Verschickt wird erst in +// sendInvite() — siehe SyncSeatToNextcloudTest. Die Sitzplatzgrenze und die +// Sperre gegen doppelte Adressen sind dabei unveraendert geblieben. + +it('adds a seat and blocks duplicates', function () { ['user' => $user, 'customer' => $customer] = seatSetup(); Livewire::actingAs($user)->test(Users::class) - ->set('inviteEmail', 'new@seat.test')->set('inviteRole', 'member')->call('invite') + ->set('inviteEmail', 'new@seat.test')->set('inviteRole', 'member')->call('addSeat') ->assertHasNoErrors(); expect($customer->seats()->where('email', 'new@seat.test')->exists())->toBeTrue(); Livewire::actingAs($user)->test(Users::class) - ->set('inviteEmail', 'new@seat.test')->call('invite') + ->set('inviteEmail', 'new@seat.test')->call('addSeat') ->assertHasErrors(['inviteEmail']); expect($customer->seats()->where('email', 'new@seat.test')->count())->toBe(1); }); @@ -49,7 +54,7 @@ it('enforces the plan seat limit', function () { Seat::factory()->count(5)->create(['customer_id' => $customer->id]); Livewire::actingAs($user)->test(Users::class) - ->set('inviteEmail', 'over@seat.test')->call('invite') + ->set('inviteEmail', 'over@seat.test')->call('addSeat') ->assertHasErrors(['inviteEmail']); expect($customer->seats()->where('email', 'over@seat.test')->exists())->toBeFalse(); }); @@ -60,18 +65,31 @@ it('will not remove or demote the last owner', function () { $owner = $customer->seats()->where('role', 'owner')->first(); Livewire::actingAs($user)->test(Users::class)->call('revoke', $owner->uuid); - expect($customer->seats()->whereKey($owner->id)->exists())->toBeTrue(); + // Auf den STATUS geprueft, nicht mehr nur auf die Zeile: seit revoke() + // grundsaetzlich nicht mehr loescht, bewiese eine noch vorhandene Zeile + // gar nichts — sie bliebe auch dann stehen, wenn die Inhaber-Sperre + // versagte. Der Status ist die Zusicherung, die hier gemeint war. + expect($customer->seats()->whereKey($owner->id)->exists())->toBeTrue() + ->and($owner->fresh()->status)->toBe('active'); Livewire::actingAs($user)->test(Users::class)->call('setRole', $owner->uuid, 'member'); expect($owner->fresh()->role)->toBe('owner'); }); -it('revokes a non-owner seat', function () { +it('revokes a non-owner seat without deleting the row', function () { + // Frueher wurde die Zeile geloescht. Das faellt weg: ein Fehlgriff im + // Userpanel darf die Arbeit eines Menschen nicht vernichten, und in einem + // Produkt, das mit Nachvollziehbarkeit verkauft wird, gaebe es danach + // nichts mehr zu zeigen, wer welchen Zugang hatte. Geloescht wird, wenn + // ueberhaupt, in der Nextcloud selbst — dort fragt Nextcloud nach, was mit + // den Dateien geschehen soll. ['user' => $user, 'customer' => $customer] = seatSetup(); $seat = Seat::factory()->create(['customer_id' => $customer->id, 'role' => 'member']); Livewire::actingAs($user)->test(Users::class)->call('revoke', $seat->uuid); - expect($customer->seats()->whereKey($seat->id)->exists())->toBeFalse(); + + expect($customer->seats()->whereKey($seat->id)->exists())->toBeTrue() + ->and($seat->fresh()->status)->toBe('revoked'); }); it('shows the actions column even when the owner is the only seat', function () { @@ -244,5 +262,8 @@ it('revokes the seat once the page receives the confirmed event', function () { Livewire::actingAs($user)->test(Users::class)->call('onRevokeConfirmed', $seat->uuid); - expect($customer->seats()->whereKey($seat->id)->exists())->toBeFalse(); + // Wieder der Status statt der Existenz: revoke() loescht nicht mehr (siehe + // die Notiz weiter oben), also waere „die Zeile ist weg" jetzt eine + // Zusicherung, die das Produkt bewusst nicht mehr gibt. + expect($seat->fresh()->status)->toBe('revoked'); });