Fuenf geparkte Kleinigkeiten nach der Mitarbeiterverwaltung
Drei eigene Saetze statt einer geteilten Meldung an den drei Inhaber-Wachen (sperren, Rolle aendern, entfernen) — jede nennt jetzt den Grund: der Inhaber-Sitz ist der Zugang, mit dem der Kunde seine eigene Cloud verwaltet. Der Kommentar zum Auffangnetz in NextcloudUsers::applyRole() behauptete einen engeren Schutz, als &&/|| linksassoziativ tatsaechlich bilden — korrigiert statt geklammert, weil Klammern die gerade erst gebaute || true-Erkennung in FakeProxmoxClient gebrochen haetten, fuer einen in der Praxis folgenlosen Fall. Beide Fakes (FakeProxmoxClient, FakeRemoteShell) verweisen jetzt im Kopfkommentar aufeinander: sie behandeln ein angehaengtes || true unterschiedlich, und wer nur den einen kennt, soll das an der Klasse lesen koennen. Die Restnaht beim nachgeschickten Sperren (SyncSeatToNextcloud::invite()) bleibt Code wie er ist — Begruendung als Kommentar an der Stelle: die Naht ist real, aber folgenlos (kein nc_synced_at, kein Wiederholen-Knopf, Weg zurueck bleibt die Vordertuer). Und ein kurzer Satz an der restore-Aktion in retry(), der die schon vorhandene, aber weit oben stehende Begruendung buendelt: kein Feld fuer ein faelliges enable, weil das die naechste Behauptung ueber eine Cloud waere, in die das Portal nicht sehen kann. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feat/versandtakt
parent
b50cc1968e
commit
9f2a45c7bf
|
|
@ -343,6 +343,10 @@ class Users extends Component
|
|||
$action = match (true) {
|
||||
in_array($seat->status, ['revoked', 'suspended'], true) => 'disable',
|
||||
$seat->nc_synced_at === null => 'invite',
|
||||
// Fünfte Aktion statt gemerkter letzter Auftrag: woran ein fälliges
|
||||
// `enable` zu erkennen wäre, steht nicht am Sitz — ein Feld dafür
|
||||
// wäre die nächste Behauptung des Portals über eine Cloud, in die
|
||||
// es nicht sehen kann. Siehe den Absatz oben.
|
||||
default => 'restore',
|
||||
};
|
||||
|
||||
|
|
@ -496,7 +500,7 @@ class Users extends Component
|
|||
}
|
||||
|
||||
if ($seat->role === 'owner') {
|
||||
$this->dispatch('notify', message: __('users.owner_locked'));
|
||||
$this->dispatch('notify', message: __('users.owner_role_locked'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
|
@ -528,7 +532,7 @@ class Users extends Component
|
|||
$seat = $customer->seats()->where('uuid', $uuid)->first();
|
||||
|
||||
if ($seat === null || $seat->role === 'owner') {
|
||||
// The owner cannot lock themselves out of their own cloud.
|
||||
// Der Inhaber sperrt sich nicht selbst aus seiner eigenen Cloud aus.
|
||||
$this->dispatch('notify', message: __('users.owner_locked'));
|
||||
|
||||
return;
|
||||
|
|
@ -571,12 +575,13 @@ class Users extends Component
|
|||
return;
|
||||
}
|
||||
|
||||
// Wortgleich zu suspend() und setRole(), und aus demselben Grund: der
|
||||
// Inhaber sperrt sich nicht selbst aus seiner eigenen Cloud aus. Die
|
||||
// frühere Wache zählte owner-Sitze — und diese Zählung liess sich
|
||||
// über setRole() vorbereiten. Siehe den Kopf von setRole().
|
||||
// Aus demselben Grund wie suspend() und setRole(): der Inhaber sperrt
|
||||
// sich nicht selbst aus seiner eigenen Cloud aus. Die frühere Wache
|
||||
// zählte owner-Sitze — und diese Zählung liess sich über setRole()
|
||||
// vorbereiten. Siehe den Kopf von setRole(). Eigene Meldung statt der
|
||||
// wortgleichen von suspend(): „entfernen" ist nicht „sperren".
|
||||
if ($seat->role === 'owner') {
|
||||
$this->dispatch('notify', message: __('users.owner_locked'));
|
||||
$this->dispatch('notify', message: __('users.owner_revoke_locked'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,6 +72,34 @@ class SyncSeatToNextcloud implements ShouldQueue
|
|||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
* Die Restnaht des K1-Fixes (queueSync()'s Ausnahme bei `pending`,
|
||||
* Users.php): ein nachgeschicktes `disable` — dispatcht, WÄHREND ein
|
||||
* `invite`-Auftrag für denselben Sitz noch in der Warteschlange
|
||||
* steht — geht davon aus, dass DIESER `invite`-Auftrag das Konto bis
|
||||
* dahin angelegt hat.
|
||||
*
|
||||
* Scheitert der `invite`-Auftrag stattdessen schon oben an
|
||||
* `no_instance` (kein seltener Fall — siehe den retry()-Kommentar in
|
||||
* Users.php: eine Instanz, die beim Absenden noch nicht aktiv war,
|
||||
* ist ein dokumentierter Weg dorthin), entsteht in der Nextcloud gar
|
||||
* nichts. Wird die Instanz genau im Fenster zwischen den beiden
|
||||
* Läufen erreichbar, kommt das nachgeschickte `disable` trotzdem bis
|
||||
* hierher und schickt `user:disable` gegen einen Benutzer, den
|
||||
* niemand angelegt hat.
|
||||
*
|
||||
* Bewusst nicht gehärtet: occ scheitert daran folgenlos (Exitcode
|
||||
* ungleich 0, in der Nextcloud ändert sich nichts), und
|
||||
* `nc_synced_at` bleibt `null` — genau daran hängt der
|
||||
* Wiederholen-Knopf im Portal (dieselbe Bedingung an zwei Stellen:
|
||||
* Users::retry() und resources/views/livewire/users.blade.php). Er
|
||||
* bleibt an dieser Zeile verborgen, weil eine entzogene oder
|
||||
* gesperrte Zeile ohne `nc_synced_at` dort nichts zu wiederholen
|
||||
* hat; der Weg zurück bleibt die Vordertür (dieselbe Adresse über
|
||||
* das Anlegen-Formular). Ein eigener „wurde je angelegt"-Zwischenstand
|
||||
* nur für dieses enge Zeitfenster wäre mehr Feld für einen Randfall,
|
||||
* der sich am Ende von selbst schliesst.
|
||||
*/
|
||||
$ok = match ($this->action) {
|
||||
'invite' => $this->invite($users, $instance, $seat),
|
||||
'role' => $users->applyRole($instance, $seat),
|
||||
|
|
|
|||
|
|
@ -93,6 +93,21 @@ class NextcloudUsers
|
|||
// Nur fürs Entfernen. Ein gescheitertes `group:adduser` bleibt
|
||||
// ein Fehlschlag: wer in keiner Gruppe landet, sieht in seiner
|
||||
// neuen Cloud nichts.
|
||||
//
|
||||
// Genauer als es oben klingt: `&&` und `||` sind in der Shell
|
||||
// linksassoziativ, und NextcloudOcc::command() setzt VOR den
|
||||
// occ-Aufruf ein eigenes `cd … &&` (siehe dort). Das fertige
|
||||
// Kommando ist damit EIN einziger Ausdruck von diesem `cd` bis
|
||||
// zum `|| true` am Ende, nicht zwei getrennte. Das Netz fängt
|
||||
// damit auch ein gescheitertes `cd` auf, nicht nur die fehlende
|
||||
// Gruppe.
|
||||
//
|
||||
// Folgenlos: `group:adduser` und die Speicherplatzzeile laufen
|
||||
// in derselben Runde OHNE eigenes Netz und ziehen den Lauf beim
|
||||
// selben `cd`-Fehlschlag ohnehin auf Fehlschlag (run() verundet
|
||||
// alle Exitcodes). Dieses Netz rettet also nichts, was den Lauf
|
||||
// sonst überstünde — es beschreibt nur mehr, als sein Kommentar
|
||||
// eben behauptet.
|
||||
foreach (array_unique(array_values(Seat::GROUPS)) as $gruppe) {
|
||||
if ($gruppe !== $ziel) {
|
||||
$befehle[] = 'group:removeuser '.escapeshellarg($gruppe).' '
|
||||
|
|
|
|||
|
|
@ -4,6 +4,18 @@ namespace App\Services\Proxmox;
|
|||
|
||||
use App\Models\Host;
|
||||
|
||||
/**
|
||||
* Test double fuer den Proxmox-Client. Gastbefehle (`guestExec()`) werden
|
||||
* nicht ausgefuehrt, sondern nur verskriptet und aufgezeichnet.
|
||||
*
|
||||
* `guestExec()` beachtet ein angehaengtes `|| true` und liefert dann immer
|
||||
* Exitcode 0 — siehe die Begruendung dort. `App\Services\Ssh\FakeRemoteShell`,
|
||||
* das Gegenstueck fuer SSH-Befehle, tut das ausdruecklich NICHT: es fuehrt
|
||||
* keine echte Shell aus und kann `|| true` daher gar nicht abbilden. Die
|
||||
* beiden Fakes behandeln dieselbe Shell-Redewendung also unterschiedlich, und
|
||||
* wer nur den einen kennt, nimmt sein Verhalten sonst irrtuemlich vom anderen
|
||||
* an — deshalb steht dieser Hinweis an beiden Stellen.
|
||||
*/
|
||||
class FakeProxmoxClient implements ProxmoxClient
|
||||
{
|
||||
public ?Host $host = null;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,14 @@ namespace App\Services\Ssh;
|
|||
|
||||
/**
|
||||
* Test double: script command output by substring, record everything.
|
||||
*
|
||||
* Anders als `App\Services\Proxmox\FakeProxmoxClient::guestExec()` beachtet
|
||||
* `run()` hier ein angehaengtes `|| true` NICHT: dieser Fake fuehrt keine
|
||||
* echte Shell aus, sondern liefert nur das verskriptete oder das
|
||||
* Standardergebnis zurueck — `|| true` ist eine Aussage der SHELL, und ohne
|
||||
* Shell gibt es nichts, das sie treffen koennte (siehe auch der Kommentar in
|
||||
* HostFirewallTest). Wer das Verhalten des anderen Fakes von hier aus
|
||||
* vermutet, irrt: die beiden behandeln dieselbe Redewendung unterschiedlich.
|
||||
*/
|
||||
class FakeRemoteShell implements RemoteShell
|
||||
{
|
||||
|
|
|
|||
|
|
@ -41,7 +41,12 @@ return [
|
|||
'reactivate' => 'Entsperren',
|
||||
'suspended' => 'Zugang gesperrt.',
|
||||
'reactivated' => 'Zugang wieder freigegeben.',
|
||||
'owner_locked' => 'Der Inhaber kann sich nicht selbst sperren.',
|
||||
// Drei Handlungen, drei eigene Sätze: der Inhaber-Sitz ist der Zugang,
|
||||
// mit dem der Kunde seine eigene Cloud verwaltet, und jeder Satz sagt,
|
||||
// was HIER gerade nicht geht — nicht nur, dass etwas gesperrt ist.
|
||||
'owner_locked' => 'Der Inhaber kann sich nicht selbst sperren — der Inhaber-Sitz ist der Zugang, mit dem der Kunde seine eigene Cloud verwaltet.',
|
||||
'owner_role_locked' => 'Die Rolle des Inhabers kann nicht geändert werden — der Inhaber-Sitz ist der Zugang, mit dem der Kunde seine eigene Cloud verwaltet.',
|
||||
'owner_revoke_locked' => 'Der Zugang des Inhabers kann nicht entfernt werden — der Inhaber-Sitz ist der Zugang, mit dem der Kunde seine eigene Cloud verwaltet.',
|
||||
'status_suspended' => 'Gesperrt',
|
||||
'status_active' => 'Aktiv',
|
||||
'status_invited' => 'Eingeladen',
|
||||
|
|
|
|||
|
|
@ -40,7 +40,12 @@ return [
|
|||
'reactivate' => 'Reactivate',
|
||||
'suspended' => 'Access suspended.',
|
||||
'reactivated' => 'Access restored.',
|
||||
'owner_locked' => 'The owner cannot lock themselves out.',
|
||||
// Three actions, three separate sentences: the owner seat is the access
|
||||
// the customer uses to manage their own cloud, and each sentence says
|
||||
// what is blocked HERE — not just that something is locked.
|
||||
'owner_locked' => 'The owner cannot lock themselves out — the owner seat is the access the customer uses to manage their own cloud.',
|
||||
'owner_role_locked' => 'The owner\'s role cannot be changed — the owner seat is the access the customer uses to manage their own cloud.',
|
||||
'owner_revoke_locked' => 'The owner\'s access cannot be removed — the owner seat is the access the customer uses to manage their own cloud.',
|
||||
'status_suspended' => 'Suspended',
|
||||
'status_active' => 'Active',
|
||||
'status_invited' => 'Invited',
|
||||
|
|
|
|||
|
|
@ -64,7 +64,11 @@ it('will not remove or demote the last owner', function () {
|
|||
Livewire::actingAs($user)->test(Users::class); // creates owner
|
||||
$owner = $customer->seats()->where('role', 'owner')->first();
|
||||
|
||||
Livewire::actingAs($user)->test(Users::class)->call('revoke', $owner->uuid);
|
||||
// Drei Handlungen, drei eigene Meldungen (users.owner_locked trug bis vor
|
||||
// kurzem alle drei) — je Knopf ein eigener Satz, der sagt, was HIER
|
||||
// gerade nicht geht.
|
||||
Livewire::actingAs($user)->test(Users::class)->call('revoke', $owner->uuid)
|
||||
->assertDispatched('notify', message: __('users.owner_revoke_locked'));
|
||||
// Auf den STATUS geprüft, nicht mehr nur auf die Zeile: seit revoke()
|
||||
// grundsätzlich nicht mehr löscht, bewiese eine noch vorhandene Zeile
|
||||
// gar nichts — sie bliebe auch dann stehen, wenn die Inhaber-Sperre
|
||||
|
|
@ -72,8 +76,13 @@ it('will not remove or demote the last owner', function () {
|
|||
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');
|
||||
Livewire::actingAs($user)->test(Users::class)->call('setRole', $owner->uuid, 'member')
|
||||
->assertDispatched('notify', message: __('users.owner_role_locked'));
|
||||
expect($owner->fresh()->role)->toBe('owner');
|
||||
|
||||
Livewire::actingAs($user)->test(Users::class)->call('suspend', $owner->uuid)
|
||||
->assertDispatched('notify', message: __('users.owner_locked'));
|
||||
expect($owner->fresh()->status)->toBe('active');
|
||||
});
|
||||
|
||||
it('revokes a non-owner seat without deleting the row', function () {
|
||||
|
|
|
|||
Loading…
Reference in New Issue