Was der Auftrag ausfuehrt, ist die Absicht von jetzt, nicht die von vorhin

Drei Befunde aus dem Gesamt-Review, alle dieselbe Klasse: die Zeile im Portal
behauptet etwas, das in der Cloud des Kunden nicht gilt.

K1 — Entziehen, waehrend die Einladung noch in der Warteschlange steht. Der
Arbeiter teilt sich die Warteschlange mit der bezahlten Bereitstellung; das
Fenster ist Minuten lang. queueSync() stieg bei nc_synced_at === null aus und
kannte damit den dritten Waechter-Fall nicht: bei `pending` entsteht dort
gerade etwas, das gesperrt werden muss. Und der invite-Auftrag liest den Status
jetzt am Ende frisch aus der Datenbank nach und schiebt bei revoked/suspended
ein disable hinterher — dieselbe Begruendung wie beim schon gebauten "ein
angelegtes Konto sofort vermerken". Vorher endete der Ablauf mit
status=revoked, nc_state=synced und einem aktiven Konto in der Nextcloud, ohne
jeden Knopf, es nachzuholen.

K2 — retry() konnte ein gescheitertes Entsperren nie wiederholen: die Ableitung
kannte disable, invite und role, aber kein enable, und waehlte deshalb role.
Der Auftrag fuhr Gruppen und Quota, gelang, die Zeile sprang auf "Aktiv" — und
user:enable war nie geschickt. Woran das zu erkennen waere, steht nirgends am
Sitz; ein Feld dafuer waere die naechste Behauptung ueber die Cloud, die
irgendwann nicht mehr stimmt. Deshalb raet retry() nicht, sondern schickt an
einer offenen Zeile beides: der neue Auftrag `restore` sperrt auf UND setzt die
Rolle. suspend() bleibt bei enable, denn dort ist bekannt, was fehlt.

W2 — der Inhaber konnte sich selbst aussperren. setRole() nahm jede Rolle aus
Seat::ROLES an, also auch owner; das Auswahlfeld bietet sie nicht an, die
Livewire-Methode ist trotzdem oeffentlich erreichbar. Mit zwei Inhaber-Sitzen
griff die Zaehlung in revoke() nicht mehr, und der echte Inhaber bekam
user:disable admin samt user:auth-tokens:delete admin in seine eigene Cloud.
revoke() und setRole() weisen owner jetzt genauso ab wie suspend(), und owner
ist keine zulaessige Zielrolle mehr. Damit faellt die Zaehlung selbst weg —
eine Sperre, die man sich erst erarbeiten muss, ist keine — und mit ihr die
Meldung users.last_owner.

Sechs Pruefungen, jede einzeln gegen den zurueckgedrehten Fix rot gesehen.
Umlaute in den beruehrten Dateien nachgezogen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/versandtakt
nexxo 2026-08-03 23:56:48 +02:00
parent 87ecb4d064
commit 1fd5d5f8d2
5 changed files with 481 additions and 187 deletions

View File

@ -51,35 +51,35 @@ class Users extends Component
}
/*
* Der Inhaber-Sitz wird mit dem bestehenden Admin-Konto verknuepft.
* Der Inhaber-Sitz wird mit dem bestehenden Admin-Konto verknüpft.
*
* 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
* Dieses Konto gibt es in der Nextcloud längst CreateCustomerAdmin
* hat es beim Aufbau angelegt. Ohne diese Verknüpfung stünde der Sitz
* auf `none`, und das Panel böte dem Inhaber an, sich SELBST
* einzuladen; der Auftrag träfe 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 ohne Verknuepfung. Der
* öffnet, bevor die Bereitstellung das Admin-Konto angelegt hat,
* behielte sonst für immer einen owner-Sitz ohne Verknüpfung. Der
* Aufruf ist folgenlos, solange es kein Konto gibt.
*
* Die Bedingung haengt an `nc_username`, weil das die Angabe ist, um
* die es geht: genau einmal gesetzt, danach unveraenderlich. Sie ist in
* Die Bedingung hängt an `nc_username`, weil das die Angabe ist, um
* die es geht: genau einmal gesetzt, danach unveränderlich. Sie ist in
* jedem ERREICHBAREN Zustand gleichbedeutend mit
* `nc_state === STATE_NONE` sie heilt also NICHTS, was
* `=== STATE_NONE` nicht auch heilen wuerde, und insbesondere heilt sie
* einen bereits beschaedigten Sitz NICHT: der traegt ja einen
* `=== STATE_NONE` nicht auch heilen würde, und insbesondere heilt sie
* einen bereits beschädigten Sitz NICHT: der trägt ja einen
* `nc_username`, und dann greift sie nie.
*
* Beschaedigt heisst hier: Inhaber-Sitz mit gesetztem `nc_username`, der
* Beschädigt heisst hier: Inhaber-Sitz mit gesetztem `nc_username`, der
* nicht das Admin-Konto der Instanz ist. Der Weg dorthin ist seit dem
* Fix zu (sendInvite() und retry() weisen `owner` ab), und im Bestand
* duerfte es keinen geben, weil saemtliche `nc_*`-Spalten aus der
* Wanderung von heute stammen. Taeucht doch einer auf, hilft nur ein
* dürfte es keinen geben, weil mtliche `nc_*`-Spalten aus der
* Wanderung von heute stammen. Taucht doch einer auf, hilft nur ein
* Eingriff an der Zeile nicht diese Bedingung.
*
* Die Wanderung aus Aufgabe 5 tut dasselbe fuer den Bestand sie fuehrt
* Die Wanderung aus Aufgabe 5 tut dasselbe für den Bestand sie hrt
* ihre eigene, eingefrorene Fassung. Hier gilt die laufende.
*/
$owner = $customer->seats()->where('role', 'owner')->first();
@ -90,19 +90,19 @@ class Users extends Component
}
/**
* Anlegen der erste der beiden Vorgaenge.
* Anlegen der erste der beiden Vorgänge.
*
* Schickt ausdruecklich NICHTS los und laesst `nc_state` auf `none`: ein
* Inhaber soll sein Team vorbereiten koennen, ohne dass jemand eine Mail
* Schickt ausdrücklich NICHTS los und sst `nc_state` auf `none`: ein
* Inhaber soll sein Team vorbereiten können, ohne dass jemand eine Mail
* bekommt. Erst `sendInvite()` erzeugt einen Benutzer im Gast.
*
* Und es ist zugleich die Vordertuer zurueck: ein entzogener Sitz derselben
* Und es ist zugleich die Vordertür zurück: ein entzogener Sitz derselben
* Adresse wird hier WIEDER AUFGENOMMEN, statt an der Eindeutigkeit von
* (customer_id, email) abzuprallen. Wer geht und ein Jahr spaeter
* wiederkommt, muss wieder Zugang bekommen koennen; verhindert werden soll
* (customer_id, email) abzuprallen. Wer geht und ein Jahr später
* wiederkommt, muss wieder Zugang bekommen können; verhindert werden soll
* die VERSEHENTLICHE Wiederbelebung mit zwei Klicks an der Zeile und die
* ginge an der Platzgrenze vorbei, die nur hier steht. Hier nicht: die
* Wiederaufnahme geht durch dieselbe Pruefung wie jeder neue Sitz.
* Wiederaufnahme geht durch dieselbe Prüfung wie jeder neue Sitz.
*/
public function addSeat(): void
{
@ -123,9 +123,9 @@ class Users extends Component
return 'duplicate';
}
// Der entzogene Sitz zaehlt heute NICHT gegen die Grenze
// (usedSeats() laesst 'revoked' aus). Sobald er wieder auf
// 'invited' steht, zaehlt er — die Pruefung gehoert also davor,
// Der entzogene Sitz zählt heute NICHT gegen die Grenze
// (usedSeats() lässt 'revoked' aus). Sobald er wieder auf
// 'invited' steht, zählt er — die Prüfung gehört also davor,
// genau wie beim Anlegen.
if ($this->usedSeats($locked) >= $this->seatLimit($locked)) {
return 'limit';
@ -133,15 +133,15 @@ class Users extends Component
if ($vorhanden !== null) {
// `nc_username` und `nc_synced_at` bleiben, wie sie sind: der
// Anmeldename gehoert weiter dieser Person (Nextcloud kann
// Anmeldename gehört weiter dieser Person (Nextcloud kann
// nicht umbenennen), und dass das Konto im Gast existiert,
// bleibt wahr — daran haengt spaeter das Entsperren.
// bleibt wahr — daran hängt später das Entsperren.
//
// `nc_state` dagegen MUSS zurueck auf `none`: der Sitz stand
// `nc_state` dagegen MUSS zurück auf `none`: der Sitz stand
// auf `synced`, und `status = 'invited'` daneben liesse die
// Zeile „Eingeladen" zeichnen — fuer jemanden, den niemand
// Zeile „Eingeladen" zeichnen — für jemanden, den niemand
// eingeladen hat und dessen Konto im Gast weiter gesperrt ist.
// Dieselbe Luege wie eine Zeile, die einen Fehlschlag
// Dieselbe Lüge wie eine Zeile, die einen Fehlschlag
// verschweigt, nur in die andere Richtung. Mit `none` heisst
// sie „angelegt — noch nicht eingeladen", der Knopf heisst
// „Einladen", und beides stimmt.
@ -187,9 +187,9 @@ class Users extends Component
/**
* 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.
* Anlegen und Einladen sind ausdrücklich zwei Vorgänge: ein Inhaber
* soll sein Team vorbereiten und die Einladungen später verschicken
* können, etwa alle am ersten Arbeitstag.
*/
public function sendInvite(string $uuid): void
{
@ -205,8 +205,8 @@ class Users extends Component
return;
}
// Entzogen bleibt entzogen. Ohne diese Zeile waere „Einladen" an einer
// entzogenen Zeile der Weg zurueck in einen Zugang, den der Inhaber
// Entzogen bleibt entzogen. Ohne diese Zeile wäre „Einladen" an einer
// entzogenen Zeile der Weg zurück in einen Zugang, den der Inhaber
// beendet hat — und er ginge an der Platzgrenze vorbei, die nur in
// addSeat() steht. Der richtige Weg ist ein NEUER Sitz.
if ($seat->status === 'revoked') {
@ -215,10 +215,10 @@ class Users extends Component
return;
}
// Den Inhaber laedt niemand ein. Sein Konto legt die Bereitstellung an
// (CreateCustomerAdmin); ein Auftrag von hier aus wuerde entweder auf
// Den Inhaber lädt niemand ein. Sein Konto legt die Bereitstellung an
// (CreateCustomerAdmin); ein Auftrag von hier aus würde entweder auf
// einen bestehenden Benutzer treffen oder — wenn die Bereitstellung
// noch nicht so weit ist — spaeter ein ZWEITES Konto in der Gruppe
// noch nicht so weit ist — später ein ZWEITES Konto in der Gruppe
// `admin` anlegen, neben dem echten.
if ($seat->role === 'owner') {
$this->dispatch('notify', message: __('users.owner_not_invitable'));
@ -232,8 +232,8 @@ class Users extends Component
return;
}
// Einmal gesetzt, nie wieder geaendert: Nextcloud kann Benutzer nicht
// umbenennen. Ein Sitz, dessen Adresse sich spaeter aendert, behaelt
// Einmal gesetzt, nie wieder geändert: Nextcloud kann Benutzer nicht
// umbenennen. Ein Sitz, dessen Adresse sich später ändert, behält
// seinen Anmeldenamen.
if (blank($seat->nc_username)) {
$seat->nc_username = $seat->email;
@ -256,13 +256,13 @@ class Users extends Component
* 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
* war, nur die Zeile zu löschen 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
* also gar nicht schneller gedrückt werden, als die Warteschlange
* antwortet und ihn zu drosseln hiesse, die Rückfahrkarte aus einem
* Fehlschlag zu drosseln.
*/
public function retry(string $uuid): void
@ -280,9 +280,9 @@ class Users extends Component
}
// Dieselbe Wache wie in sendInvite(), und aus demselben Grund: an einem
// beschaedigten Inhaber-Sitz (`failed`, nie gespiegelt) waehlte das
// beschädigten Inhaber-Sitz (`failed`, nie gespiegelt) wählte das
// match unten `invite` und legte in der Nextcloud ein ZWEITES Konto in
// der Gruppe `admin` an, neben dem echten. Gehaertet war bisher nur der
// der Gruppe `admin` an, neben dem echten. Gehärtet war bisher nur der
// Knopf davor, nicht der Auftragsweg dahinter.
if ($seat->role === 'owner') {
$this->dispatch('notify', message: __('users.owner_not_invitable'));
@ -290,20 +290,37 @@ class Users extends Component
return;
}
// Ein ENTZOGENER Sitz darf hier ausdruecklich durch. Er ist der Fall,
// fuer den dieser Knopf am dringendsten gebraucht wird: scheitert das
// `disable`, ist das Konto im Gast weiter offen, waehrend die Zeile
// „entzogen" sagt. Gefaehrlich waere nur ein Weg zurueck — den gibt es
// Ein ENTZOGENER Sitz darf hier ausdrücklich durch. Er ist der Fall,
// für den dieser Knopf am dringendsten gebraucht wird: scheitert das
// `disable`, ist das Konto im Gast weiter offen, während die Zeile
// „entzogen" sagt. Gefährlich wäre nur ein Weg zurück — den gibt es
// nicht, das match unten bildet 'revoked' ausnahmslos auf `disable` ab.
//
// 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
// ankam (`nc_synced_at`). Den letzten Auftrag mitzuschreiben wäre ein
// Feld, das nach dem ersten Erfolg nie wieder stimmt.
//
// Der letzte Zweig hiess `role`, und darin steckte ein Loch: `enable`
// stand in keinem. Ein gescheitertes Entsperren wurde damit als
// Rollenänderung wiederholt — die fuhr Gruppen und Quota, gelang, die
// Zeile sprang auf „Aktiv", und `user:enable` war nie geschickt. Der
// Mitarbeiter kam weiter nicht hinein, der Knopf war weg.
//
// Woran erkennt dieser Zweig, dass zuletzt ein `enable` fällig war?
// Gar nicht — der Statuswechsel trägt es nicht ('active' ist auch
// der Normalzustand), und ein Feld dafür wäre wieder eine
// Behauptung über die Cloud des Kunden, die irgendwann nicht mehr
// stimmt. Deshalb rät er nicht, sondern schickt an einer OFFENEN
// Zeile beides: aufsperren UND die Rolle setzen (`restore`). Welcher
// der beiden Befehle beim letzten Mal scheiterte, spielt dann keine
// Rolle mehr.
//
// `suspend()` bleibt bei `enable`: dort IST bekannt, was fehlt.
$action = match (true) {
in_array($seat->status, ['revoked', 'suspended'], true) => 'disable',
$seat->nc_synced_at === null => 'invite',
default => 'role',
default => 'restore',
};
// Derselbe Vorbehalt wie in queueSync(), und er fehlte hier: war dieser
@ -313,7 +330,7 @@ class Users extends Component
// `no_instance`, entziehen (queueSync bricht korrekt ab, `failed`
// bleibt stehen), und jeder Druck schickte ein `user:disable` gegen
// einen Benutzer, den es nie gab. Genau der Dauerfehler, den
// queueSync() schon abgestellt hat, nur ueber die andere Tuer.
// queueSync() schon abgestellt hat, nur über die andere Tür.
//
// `invite` ist ausgenommen: das ist der Fall, FUER den dieser Knopf
// gebaut ist — dort soll das Konto ja erst entstehen.
@ -329,11 +346,11 @@ class Users extends Component
/**
* Zwei Grenzen, beide aus dem Betrieb heraus gefordert: eine je Kunde
* gegen den Rundumschlag, eine je Sitz gegen das wiederholte Draufdruecken
* gegen den Rundumschlag, eine je Sitz gegen das wiederholte Draufdrücken
* an derselben Zeile.
*
* Gibt die Restzeit in Minuten zurueck, oder null wenn frei. Eine stumme
* Verweigerung waere schlimmer als die Grenze selbst.
* Gibt die Restzeit in Minuten zurück, oder null wenn frei. Eine stumme
* Verweigerung wäre schlimmer als die Grenze selbst.
*/
private function rateLimited(Customer $customer, Seat $seat): ?int
{
@ -354,23 +371,34 @@ class Users extends Component
/**
* Ein Sitz, der noch nie erfolgreich in der Nextcloud angekommen ist,
* bekommt keinen Auftrag dort gaebe es nichts zu aendern.
* bekommt keinen Auftrag dort gäbe es nichts zu ändern.
*
* Massgeblich ist `nc_synced_at`, nicht `nc_state`: `nc_state` sagt nur,
* wie der LETZTE Versuch ausging. Eine Einladung, die an einem nicht
* erreichbaren Gast gescheitert ist, hinterlaesst einen Sitz mit
* erreichbaren Gast gescheitert ist, hinterlässt einen Sitz mit
* Anmeldenamen und `failed` in der Nextcloud aber nichts. Ein
* `user:disable` darauf muss scheitern und liesse die Zeile dauerhaft rot
* stehen, mit einem Knopf, der nur weiter scheitert: eine Fehlermeldung
* fuer etwas, das nie ein Fehler war.
* für etwas, das nie ein Fehler war.
*
* Das traegt nur, weil der Auftrag ein angelegtes Konto SOFORT vermerkt,
* Das trägt nur, weil der Auftrag ein angelegtes Konto SOFORT vermerkt,
* auch wenn die Gruppe danach scheitert siehe SyncSeatToNextcloud. Sonst
* bliebe genau der gefaehrliche Fall offen: Konto da, Sperre nie geschickt.
* bliebe genau der gefährliche Fall offen: Konto da, Sperre nie geschickt.
*
* EINE Ausnahme, und sie war der dritte, fehlende Wächter-Fall: steht
* `pending`, ist gerade ein Auftrag unterwegs. Dann entsteht dort in
* diesem Moment etwas, das gesperrt werden muss und weil der Arbeiter
* sich die Warteschlange mit der bezahlten Bereitstellung teilt, sind das
* Minuten, nicht Millisekunden. Der nachgeschickte Auftrag läuft hinter
* dem ersten (dieselbe serielle Warteschlange, dieselbe Reihenfolge).
*
* Der Auftrag selbst prüft die Absicht am Ende noch einmal nach; diese
* Zeile ist die Rückfahrkarte für den Fall, dass er dabei schon vorbei
* war.
*/
private function queueSync(Seat $seat, string $action): void
{
if ($seat->nc_synced_at === null) {
if ($seat->nc_synced_at === null && $seat->nc_state !== Seat::STATE_PENDING) {
return;
}
@ -378,42 +406,56 @@ class Users extends Component
SyncSeatToNextcloud::dispatch($seat->uuid, $action);
}
/**
* Die Rolle umstellen an jedem Sitz ausser dem des Inhabers.
*
* `owner` ist hier weder Ziel noch Ausgangspunkt, und beides ist nötig.
* Die früheren Wachen zählten nur owner-Sitze, und diese Zählung liess
* sich vorbereiten: `setRole(<fremderSitz>, 'owner')` das Auswahlfeld
* bietet die Rolle nicht an, die Livewire-Methode ist trotzdem öffentlich
* erreichbar machte zwei owner, und danach griff keine Wache mehr. Der
* Inhaber liess sich entziehen (`user:disable admin` samt
* `user:auth-tokens:delete admin` in seiner EIGENEN Cloud) oder auf
* `member` stellen, womit das Admin-Konto im eigenen Gast seine
* Adminrechte verlor. Vor dem Anschluss an die Nextcloud war das folgenlos
* seit ihm wirkt es.
*
* Damit ist die Zählung selbst weg: wer nicht zum Inhaber gemacht werden
* kann und dem Inhaber nichts wegnehmen kann, braucht kein „ist noch
* einer übrig?". Eine Sperre, die man sich erst erarbeiten muss, ist
* keine.
*/
public function setRole(string $uuid, string $role): void
{
if (! in_array($role, Seat::ROLES, true)) {
// `Seat::ROLES` enthält `owner` — die Menge, die HIER zulässig ist,
// ist eine andere. Dieselbe Liste wie im Auswahlfeld der Zeile.
if (! in_array($role, ['admin', 'member', 'readonly'], true)) {
return;
}
$customer = $this->requireCustomer();
if ($customer === null) {
return;
}
// Lock the customer so a concurrent owner change can't race past the guard.
$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 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]);
$seat = $customer->seats()->where('uuid', $uuid)->first();
return $seat;
});
if ($seat === null) {
return;
}
if ($seat === false) {
$this->dispatch('notify', message: __('users.last_owner'));
if ($seat->role === 'owner') {
$this->dispatch('notify', message: __('users.owner_locked'));
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');
}
$seat->update(['role' => $role]);
// Erst nach dem Schreiben: ein Auftrag, den die Warteschlange
// schneller aufnimmt als die Zeile geschrieben ist, liest die alte
// Rolle.
$this->queueSync($seat, 'role');
}
/**
@ -442,11 +484,11 @@ class Users extends Component
}
// Entzogen bleibt entzogen. Der Umschalter unten kennt nur zwei
// Zustaende: an einem entzogenen Sitz machte er beim ersten Klick
// Zustände: an einem entzogenen Sitz machte er beim ersten Klick
// `suspended` und beim zweiten `active` — samt `user:enable`. Der
// Mensch, dessen Zugang der Inhaber beendet hat, koennte sich wieder
// anmelden, und er zaehlte wieder gegen die Platzgrenze, ohne dass sie
// hier jemals geprueft wird.
// Mensch, dessen Zugang der Inhaber beendet hat, könnte sich wieder
// anmelden, und er zählte wieder gegen die Platzgrenze, ohne dass sie
// hier jemals geprüft wird.
if ($seat->status === 'revoked') {
$this->dispatch('notify', message: __('users.revoked_closed'));
@ -456,7 +498,7 @@ 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
// arbeitet der Gesperrte weiter. Deshalb fährt die Sperre in den Gast
// und nicht nur in die Statusspalte.
$this->queueSync($seat, $seat->status === 'suspended' ? 'disable' : 'enable');
@ -472,35 +514,30 @@ class Users extends Component
return;
}
$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 null;
}
if ($seat->role === 'owner' && $customer->seats()->where('role', 'owner')->count() <= 1) {
return false;
}
$seat = $customer->seats()->where('uuid', $uuid)->first();
// 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']);
if ($seat === null) {
return;
}
return $seat;
});
if ($seat === false) {
$this->dispatch('notify', message: __('users.last_owner'));
// 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().
if ($seat->role === 'owner') {
$this->dispatch('notify', message: __('users.owner_locked'));
return;
}
if ($seat instanceof Seat) {
$this->queueSync($seat, 'disable');
$this->dispatch('notify', message: __('users.revoked'));
}
// Nicht löschen. Der Zugang ist zu, die Arbeit bleibt dort, wo sein
// Team sie braucht. Wer wirklich löschen will, tut das in der
// Nextcloud, wo Nextcloud danach fragt, was mit den Dateien geschehen
// soll.
$seat->update(['status' => 'revoked']);
$this->queueSync($seat, 'disable');
$this->dispatch('notify', message: __('users.revoked'));
}
/**

View File

@ -15,17 +15,17 @@ use Throwable;
/**
* Spiegelt einen Sitz in die Nextcloud des Kunden.
*
* Laeuft auf der `provisioning`-Warteschlange, weil nur dieser Arbeiter den
* Läuft auf der `provisioning`-Warteschlange, weil nur dieser Arbeiter den
* Tunnel und die Proxmox-Zugangsdaten hat das Portal hat weder das eine noch
* das andere und darf es auch gar nicht versuchen.
*
* Daraus folgt: der Knopf wirkt nicht sofort. Deshalb schreibt dieser Auftrag
* zurueck, was WIRKLICH passiert ist (`nc_state`, `nc_error`) sonst drueckt
* zurück, was WIRKLICH passiert ist (`nc_state`, `nc_error`) sonst drückt
* der Inhaber wieder und wieder, weil nichts sichtbar geschieht. Genau die
* Beschwerde, die dieses Projekt schon zweimal hatte.
*
* `tries = 1`: ein blinder Wiederholungslauf gegen einen nicht erreichbaren
* Gast bringt nichts, und der Inhaber hat einen Knopf dafuer.
* Gast bringt nichts, und der Inhaber hat einen Knopf dafür.
*/
class SyncSeatToNextcloud implements ShouldQueue
{
@ -35,7 +35,7 @@ class SyncSeatToNextcloud implements ShouldQueue
public function __construct(
public string $seatUuid,
/** invite | role | disable | enable */
/** invite | role | disable | enable | restore */
public string $action,
) {
$this->onConnection('provisioning');
@ -59,7 +59,7 @@ class SyncSeatToNextcloud implements ShouldQueue
return;
}
// Erneut geprueft, nicht der Seite geglaubt: eine geschlossene Instanz
// Erneut geprüft, 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()
@ -77,6 +77,15 @@ class SyncSeatToNextcloud implements ShouldQueue
'role' => $users->applyRole($instance, $seat),
'disable' => $users->disable($instance, $seat),
'enable' => $users->enable($instance, $seat),
// Der Wiederholen-Knopf an einer OFFENEN Zeile. Er weiss nicht,
// welcher der beiden Befehle beim letzten Mal scheiterte — das
// stünde nirgends am Sitz, und ein Feld dafür wäre eine
// weitere Behauptung über die Cloud des Kunden, die irgendwann
// nicht mehr stimmt. Beide zu schicken ist billiger und ehrlicher
// als es zu raten; `user:enable` auf ein offenes Konto ist
// folgenlos, und das Setzen der Rolle ist es ohnehin. Siehe
// Users::retry().
'restore' => $users->enable($instance, $seat) && $users->applyRole($instance, $seat),
default => false,
};
@ -90,13 +99,13 @@ class SyncSeatToNextcloud implements ShouldQueue
*
* Das Anlegen wird dabei SOFORT vermerkt, noch bevor die Gruppe gesetzt
* wird. `nc_synced_at` beantwortet die Frage „gibt es dieses Konto dort
* ueberhaupt?", und ab dem geglueckten `user:add` lautet die Antwort ja —
* auch wenn der naechste Befehl scheitert. Die Seite entscheidet genau
* daran, ob ein spaeteres Sperren etwas zu sperren haette; ohne diese Zeile
* bliebe der gefaehrlichste Fall offen: Konto angelegt, Rolle gescheitert,
* überhaupt?", und ab dem geglückten `user:add` lautet die Antwort ja —
* auch wenn der nächste Befehl scheitert. Die Seite entscheidet genau
* daran, ob ein späteres Sperren etwas zu sperren tte; ohne diese Zeile
* bliebe der gefährlichste Fall offen: Konto angelegt, Rolle gescheitert,
* Zugang entzogen und nie eine Sperre losgeschickt.
*
* `nc_state` bleibt davon unberuehrt: der Versuch ist erst gelungen, wenn
* `nc_state` bleibt davon unberührt: der Versuch ist erst gelungen, wenn
* auch die Rolle sitzt.
*/
private function invite(NextcloudUsers $users, Instance $instance, Seat $seat): bool
@ -104,9 +113,9 @@ class SyncSeatToNextcloud implements ShouldQueue
// VOR dem Vermerk gelesen: war dieser Sitz schon einmal in der
// Nextcloud, ist dies eine Wiederaufnahme — und sein Konto dort steht
// gesperrt, weil `revoke()` ein `disable` geschickt hat. `user:welcome`
// macht eine Sperre nicht rueckgaengig; ohne diese Zeile bekaeme der
// Wiederaufgenommene eine Willkommensmail zu einer Tuer, die zu bleibt.
// Bei einem frisch angelegten Konto entfaellt der Befehl ganz, statt
// macht eine Sperre nicht rückgängig; ohne diese Zeile bekäme der
// Wiederaufgenommene eine Willkommensmail zu einer Tür, die zu bleibt.
// Bei einem frisch angelegten Konto entfällt der Befehl ganz, statt
// sich auf ein folgenloses `user:enable` zu verlassen.
$wiederaufnahme = $seat->nc_synced_at !== null;
@ -116,11 +125,45 @@ class SyncSeatToNextcloud implements ShouldQueue
$seat->forceFill(['nc_synced_at' => now()])->save();
if ($wiederaufnahme && ! $users->enable($instance, $seat)) {
/*
* Zwischen Absenden und Ausführen kann sich die Absicht geändert
* haben dieselbe Begründung wie beim Vermerk oben.
*
* Der Arbeiter teilt sich die Warteschlange mit der bezahlten
* Bereitstellung; das Fenster ist MINUTEN lang, nicht Millisekunden.
* In dieser Zeit merkt ein Inhaber seinen Fehlgriff und entzieht. Ohne
* diese Zeilen führe der Auftrag danach blind aus, was beim Absenden
* galt: die Zeile zeichnete „Entfernt" und in der Cloud stände ein
* aktives, freigeschaltetes Konto.
*
* Frisch aus der Datenbank, nicht am mitgeschleppten Modell: dieses
* Objekt stammt vom Anfang des Auftrags und trägt den Status von
* damals.
*/
$gesperrtGewollt = in_array(
Seat::query()->where('uuid', $seat->uuid)->value('status'),
['revoked', 'suspended'],
true,
);
// Eine Tür aufzumachen, um sie im selben Auftrag wieder zuzuziehen,
// wäre im besten Fall folgenlos und im schlechteren ein offenes
// Zeitfenster.
if ($wiederaufnahme && ! $gesperrtGewollt && ! $users->enable($instance, $seat)) {
return false;
}
return $users->applyRole($instance, $seat);
$rolleOk = $users->applyRole($instance, $seat);
if (! $gesperrtGewollt) {
return $rolleOk;
}
// Die Sperre geht auch dann noch hinaus, wenn die Rolle scheiterte:
// an ihr hängt ein beendeter Zugang, und eine misslungene Gruppe darf
// ihn nicht offen lassen. Gemeldet wird trotzdem beides — die Zeile
// stimmt nur, wenn sie den Fehlschlag zeigt.
return $users->disable($instance, $seat) && $rolleOk;
}
/**

View File

@ -93,7 +93,6 @@ return [
'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' => 'Für diese E-Mail gibt es bereits einen Platz.',
];

View File

@ -92,7 +92,6 @@ return [
'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 already has a seat.',
];

View File

@ -64,7 +64,7 @@ it('sagt es am Sitz, wenn es gar keine erreichbare Instanz gibt', function () {
->and($sitz->fresh()->nc_error)->toBe('no_instance');
});
it('laeuft auf der Bereitstellungs-Warteschlange, nirgends sonst', function () {
it('läuft 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');
@ -75,7 +75,7 @@ it('laeuft auf der Bereitstellungs-Warteschlange, nirgends sonst', function () {
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.
// können, ohne dass jemand eine Mail bekommt.
Queue::fake();
$customer = Customer::factory()->create();
$user = $customer->ensureUser();
@ -104,9 +104,9 @@ it('schickt erst beim Einladen einen Auftrag los', function () {
->and($sitz->fresh()->nc_username)->toBe($sitz->email);
});
it('haelt den Benutzernamen fest, auch wenn die Mailadresse sich aendert', function () {
it('hält den Benutzernamen fest, auch wenn die Mailadresse sich ändert', function () {
// Nextcloud kann Benutzer nicht umbenennen. Ein Sitz, dessen Adresse sich
// spaeter aendert, behaelt seinen Anmeldenamen.
// später ändert, behält seinen Anmeldenamen.
Queue::fake();
$customer = Customer::factory()->create();
$user = $customer->ensureUser();
@ -146,7 +146,7 @@ it('weist die elfte Einladung derselben Stunde ab und sagt die Restzeit', functi
Queue::assertPushed(SyncSeatToNextcloud::class, 10);
});
it('weist das vierte Draufdruecken auf denselben Sitz ab', function () {
it('weist das vierte Draufdrücken auf denselben Sitz ab', function () {
// Die zweite Grenze: gegen das wiederholte Klicken an EINER Zeile, das
// demselben Menschen sonst vier Willkommensmails schickt.
Queue::fake();
@ -164,7 +164,7 @@ it('weist das vierte Draufdruecken auf denselben Sitz ab', function () {
Queue::assertPushed(SyncSeatToNextcloud::class, 3);
});
it('entzieht, ohne die Zeile zu loeschen', function () {
it('entzieht, ohne die Zeile zu löschen', function () {
Queue::fake();
$customer = Customer::factory()->create();
$user = $customer->ensureUser();
@ -180,13 +180,13 @@ it('entzieht, ohne die Zeile zu loeschen', function () {
// 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.
// Nachvollziehbarkeit verkauft wird, gäbe 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 () {
it('schickt für 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.
@ -204,10 +204,10 @@ it('schickt fuer einen Sitz, der nie in der Nextcloud war, keinen Auftrag', func
});
it('sperrt nichts, was in der Nextcloud nie angekommen ist', function () {
// Einladung losgeschickt, Gast war unerreichbar: der Sitz traegt schon
// Einladung losgeschickt, Gast war unerreichbar: der Sitz trägt schon
// einen Anmeldenamen, in der Nextcloud existiert aber nichts. Ein
// `user:disable` darauf muesste scheitern und liesse die Zeile dauerhaft
// rot stehen — eine Fehlermeldung fuer etwas, das nie ein Fehler war.
// `user:disable` darauf müsste scheitern und liesse die Zeile dauerhaft
// rot stehen — eine Fehlermeldung für etwas, das nie ein Fehler war.
Queue::fake();
$customer = Customer::factory()->create();
$user = $customer->ensureUser();
@ -224,14 +224,14 @@ it('sperrt nichts, was in der Nextcloud nie angekommen ist', function () {
});
it('vermerkt ein angelegtes Konto, auch wenn die Rolle danach scheitert', function () {
// Der gefaehrlichste Halbschritt: `user:add` ist durch, `group:adduser`
// nicht. Das Konto GIBT es ab jetzt — wuerde der Auftrag das verschweigen,
// liesse ein spaeteres Entziehen die Sperre weg, weil die Seite den Sitz
// fuer nie angekommen hielte. Der Zustand bleibt trotzdem 'failed'.
// Der gefährlichste Halbschritt: `user:add` ist durch, `group:adduser`
// nicht. Das Konto GIBT es ab jetzt — würde der Auftrag das verschweigen,
// liesse ein späteres Entziehen die Sperre weg, weil die Seite den Sitz
// für nie angekommen hielte. Der Zustand bleibt trotzdem 'failed'.
$pve = new FakeProxmoxClient;
// user:info auf 1, sonst haelt invite() den Benutzer fuer vorhanden und
// user:info auf 1, sonst hält invite() den Benutzer für vorhanden und
// schickt nur `user:welcome` — dann liefe hier gar kein `user:add`, und der
// Ablauf waere ein anderer als der beschriebene.
// Ablauf wäre ein anderer als der beschriebene.
$pve->guestScript('user:info', 1);
$pve->guestScript('group:adduser', 1);
app()->instance(ProxmoxClient::class, $pve);
@ -246,11 +246,11 @@ it('vermerkt ein angelegtes Konto, auch wenn die Rolle danach scheitert', functi
->and($sitz->fresh()->nc_synced_at)->not->toBeNull();
});
it('macht die Sperre beim Wiederaufnehmen rueckgaengig', function () {
it('macht die Sperre beim Wiederaufnehmen rückgängig', function () {
// Ein wiederaufgenommener Sitz war schon einmal in der Nextcloud, und
// revoke() hat ihm dort ein `user:disable` geschickt. `user:welcome` hebt
// eine Sperre nicht auf — ohne `user:enable` bekaeme er eine
// Willkommensmail zu einer Tuer, die zu bleibt.
// eine Sperre nicht auf — ohne `user:enable` bekäme er eine
// Willkommensmail zu einer Tür, die zu bleibt.
$pve = new FakeProxmoxClient;
app()->instance(ProxmoxClient::class, $pve);
$customer = Customer::factory()->create();
@ -266,9 +266,9 @@ it('macht die Sperre beim Wiederaufnehmen rueckgaengig', function () {
});
it('schickt bei einer ersten Einladung kein Entsperren mit', function () {
// Ein frisch angelegtes Konto ist nicht gesperrt. Ein `user:enable` waere
// Ein frisch angelegtes Konto ist nicht gesperrt. Ein `user:enable` wäre
// hier folgenlos — und ein folgenloser Befehl im Gast ist einer, den
// niemand mehr erklaeren kann, wenn er eines Tages doch etwas tut.
// niemand mehr erklären kann, wenn er eines Tages doch etwas tut.
$pve = new FakeProxmoxClient;
$pve->guestScript('user:info', 1);
app()->instance(ProxmoxClient::class, $pve);
@ -282,7 +282,7 @@ it('schickt bei einer ersten Einladung kein Entsperren mit', function () {
->and($sitz->fresh()->nc_state)->toBe(Seat::STATE_SYNCED);
});
it('spiegelt eine Rollenaenderung in die Nextcloud', function () {
it('spiegelt eine Rollenänderung in die Nextcloud', function () {
Queue::fake();
$customer = Customer::factory()->create();
$user = $customer->ensureUser();
@ -317,12 +317,12 @@ it('gibt dem Inhaber nach einem Fehlschlag einen zweiten Versuch', function () {
->and($sitz->fresh()->nc_error)->toBeNull();
});
it('macht einen entzogenen Sitz nicht ueber das Sperren wieder aktiv', function () {
// Der Umschalter in suspend() kannte nur zwei Zustaende: an einer
it('macht einen entzogenen Sitz nicht über das Sperren wieder aktiv', function () {
// Der Umschalter in suspend() kannte nur zwei Zustände: an einer
// entzogenen Zeile machte er beim ersten Klick 'suspended' und beim
// zweiten 'active', samt `user:enable`. Der Mensch, dessen Zugang der
// Inhaber beendet hat, koennte sich wieder anmelden — und er zaehlte
// wieder gegen die Platzgrenze, die nur in addSeat() geprueft wird.
// Inhaber beendet hat, könnte sich wieder anmelden — und er zählte
// wieder gegen die Platzgrenze, die nur in addSeat() geprüft wird.
Queue::fake();
$customer = Customer::factory()->create();
$user = $customer->ensureUser();
@ -340,8 +340,8 @@ it('macht einen entzogenen Sitz nicht ueber das Sperren wieder aktiv', function
Queue::assertNothingPushed();
});
it('laedt einen entzogenen Sitz nicht erneut ein', function () {
// Derselbe Weg zurueck, nur ueber die andere Tuer — und ebenfalls an der
it('lädt einen entzogenen Sitz nicht erneut ein', function () {
// Derselbe Weg zurück, nur über die andere Tür — und ebenfalls an der
// Platzgrenze vorbei. Der richtige Weg ist ein NEUER Sitz.
Queue::fake();
$customer = Customer::factory()->create();
@ -359,7 +359,7 @@ it('laedt einen entzogenen Sitz nicht erneut ein', function () {
it('zeigt an einer entzogenen Zeile keinen Handlungsknopf mehr', function () {
// Ein Knopf, den der Server ohnehin abweist, ist eine Einladung zum
// Draufdruecken. Die Zeile sagt stattdessen, dass sie endgueltig ist.
// Draufdrücken. Die Zeile sagt stattdessen, dass sie endgültig ist.
$customer = Customer::factory()->create();
$user = $customer->ensureUser();
Instance::factory()->for($customer)->create(['status' => 'active']);
@ -378,7 +378,7 @@ it('sagt an einer entzogenen Zeile „entzogen", nicht „noch nicht eingeladen"
// Die Zustandsspalte muss „entzogen" VOR jeden nc_state stellen. Dieser
// Sitz steht auf `none` — ohne den Vorrang schriebe die Spalte „angelegt —
// noch nicht eingeladen" an einen Zugang, den der Inhaber beendet hat, und
// das laese sich wie ein Weg zurueck.
// das läse sich wie ein Weg zurück.
$customer = Customer::factory()->create();
$user = $customer->ensureUser();
Instance::factory()->for($customer)->create(['status' => 'active']);
@ -390,9 +390,9 @@ it('sagt an einer entzogenen Zeile „entzogen", nicht „noch nicht eingeladen"
->assertDontSee(__('users.state_none'));
});
it('laedt den Inhaber nicht zu seiner eigenen Cloud ein', function () {
it('lädt den Inhaber nicht zu seiner eigenen Cloud ein', function () {
// Dieses Konto legt die Bereitstellung an (CreateCustomerAdmin). Ein
// Auftrag von hier aus traefe entweder auf einen bestehenden Benutzer —
// Auftrag von hier aus träfe entweder auf einen bestehenden Benutzer —
// oder legte, solange die Bereitstellung noch nicht so weit ist, ein
// ZWEITES Konto in der Gruppe `admin` an, neben dem echten.
Queue::fake();
@ -408,18 +408,18 @@ it('laedt den Inhaber nicht zu seiner eigenen Cloud ein', function () {
->and($sitz->fresh()->nc_state)->toBe(Seat::STATE_NONE);
});
it('verknuepft den Inhaber-Sitz beim Besuch, auch wenn die Instanz spaeter dazukommt', function () {
// /users verlangt keine fertige Instanz. Wer die Seite oeffnet, bevor die
it('verknüpft den Inhaber-Sitz beim Besuch, auch wenn die Instanz später dazukommt', function () {
// /users verlangt keine fertige Instanz. Wer die Seite öffnet, bevor die
// Bereitstellung das Admin-Konto angelegt hat, bekommt einen owner-Sitz
// ohne Verknuepfung — und ohne diese Zeilen in mount() bliebe er es fuer
// immer, waehrend das Panel dem Inhaber anboete, sich SELBST einzuladen.
// ohne Verknüpfung — und ohne diese Zeilen in mount() bliebe er es für
// immer, während das Panel dem Inhaber anböte, sich SELBST einzuladen.
// Der Aufruf muss deshalb bei JEDEM Besuch stattfinden, nicht nur beim
// ersten.
$customer = Customer::factory()->create();
$user = $customer->ensureUser();
// Erster Besuch, noch ohne Instanz: mount() legt den owner-Sitz an, findet
// aber kein Konto zum Verknuepfen.
// aber kein Konto zum Verknüpfen.
Livewire::actingAs($user)->test(Users::class)->assertOk();
$sitz = $customer->seats()->where('role', 'owner')->firstOrFail();
expect($sitz->nc_username)->toBeNull();
@ -432,13 +432,13 @@ it('verknuepft den Inhaber-Sitz beim Besuch, auch wenn die Instanz spaeter dazuk
->and($sitz->fresh()->nc_state)->toBe(Seat::STATE_SYNCED);
});
it('laesst den Wiederholen-Knopf an einer entzogenen Zeile stehen, wenn das Sperren scheiterte', function () {
// Der gefaehrlichste Halbschritt auf der anderen Seite: der Inhaber
it('lässt den Wiederholen-Knopf an einer entzogenen Zeile stehen, wenn das Sperren scheiterte', function () {
// Der gefährlichste Halbschritt auf der anderen Seite: der Inhaber
// entzieht, der `disable`-Auftrag scheitert (Gast aus, Tunnel weg), und das
// Konto im Gast ist WEITER OFFEN, waehrend die Zeile „entzogen" sagt. Es
// Konto im Gast ist WEITER OFFEN, während die Zeile „entzogen" sagt. Es
// gibt keinen Wiederholungslauf (tries = 1), keinen Abgleich im Zeitplan,
// und diese Seite ist die einzige Stelle im Repo, die nc_state anzeigt —
// ohne diesen Knopf faellt es nie wieder jemandem auf.
// ohne diesen Knopf fällt es nie wieder jemandem auf.
$customer = Customer::factory()->create();
$user = $customer->ensureUser();
Instance::factory()->for($customer)->create(['status' => 'active']);
@ -476,8 +476,8 @@ it('wiederholt nichts an einer entzogenen Zeile, die nie in der Nextcloud war',
// queueSync() bricht korrekt ab, `failed` bleibt stehen, und der
// Fehlschlag-Block ist zu Recht da. Ohne Vorbehalt liefe ein Druck aber auf
// `user:disable` gegen einen Benutzer, den es nie gab: scheitert, dieselbe
// rote Zeile, beliebig oft. Genau der Dauerfehler, nur ueber die andere
// Tuer — und mit einem veralteten Grund aus einem anderen Vorgang.
// rote Zeile, beliebig oft. Genau der Dauerfehler, nur über die andere
// Tür — und mit einem veralteten Grund aus einem anderen Vorgang.
Queue::fake();
$customer = Customer::factory()->create();
$user = $customer->ensureUser();
@ -496,8 +496,8 @@ it('wiederholt nichts an einer entzogenen Zeile, die nie in der Nextcloud war',
});
it('wiederholt nichts an einem Inhaber-Sitz', function () {
// retry() war der ungehaertete Zwilling von sendInvite(): an einem
// beschaedigten Inhaber-Sitz (failed, nie gespiegelt) waehlte es die Aktion
// retry() war der ungehärtete Zwilling von sendInvite(): an einem
// beschädigten Inhaber-Sitz (failed, nie gespiegelt) wählte es die Aktion
// `invite` und legte damit genau das an, was die Wache in sendInvite()
// verhindern soll — ein ZWEITES Konto in der Gruppe `admin`.
Queue::fake();
@ -514,8 +514,8 @@ it('wiederholt nichts an einem Inhaber-Sitz', function () {
expect($sitz->fresh()->nc_state)->toBe(Seat::STATE_FAILED);
});
it('nimmt einen entzogenen Sitz ueber das Anlegen-Formular wieder auf', function () {
// Die empfohlene Vordertuer muss offen sein. Vor dem Fix prallte dieselbe
it('nimmt einen entzogenen Sitz über das Anlegen-Formular wieder auf', function () {
// Die empfohlene Vordertür muss offen sein. Vor dem Fix prallte dieselbe
// Adresse an der Eindeutigkeit von (customer_id, email) ab — die Meldung
// empfahl damit einen Weg, den es nicht gab.
Queue::fake();
@ -544,9 +544,9 @@ it('nimmt einen entzogenen Sitz ueber das Anlegen-Formular wieder auf', function
Queue::assertNothingPushed();
});
it('laesst einen wieder Aufgenommenen auch wirklich wieder hinein', function () {
// Der ganze Weg in einer Pruefung: entziehen, wieder aufnehmen, einladen.
// Zwei Dinge muessen dabei zugleich stimmen — die Zeile darf nicht
it('lässt einen wieder Aufgenommenen auch wirklich wieder hinein', function () {
// Der ganze Weg in einer Prüfung: entziehen, wieder aufnehmen, einladen.
// Zwei Dinge müssen dabei zugleich stimmen — die Zeile darf nicht
// „Eingeladen" behaupten, solange niemand eingeladen hat, und die Sperre,
// die revoke() in den Gast geschickt hat, muss beim Einladen wieder
// aufgehen. `user:welcome` allein tut das nicht.
@ -568,7 +568,7 @@ it('laesst einen wieder Aufgenommenen auch wirklich wieder hinein', function ()
expect($sitz->fresh()->nc_state)->toBe(Seat::STATE_NONE)
->and($sitz->fresh()->nc_error)->toBeNull()
// Anmeldename und Ankunftszeitpunkt bleiben: der Name gehoert dieser
// Anmeldename und Ankunftszeitpunkt bleiben: der Name gehört dieser
// Person, und dass es das Konto im Gast gibt, bleibt wahr.
->and($sitz->fresh()->nc_username)->toBe('anna@firma.tld')
->and($sitz->fresh()->nc_synced_at)->not->toBeNull();
@ -587,8 +587,8 @@ it('laesst einen wieder Aufgenommenen auch wirklich wieder hinein', function ()
});
it('weist die Wiederaufnahme ab, wenn kein Platz mehr frei ist', function () {
// Der Grund, warum es die Vordertuer sein muss und kein Knopf an der Zeile:
// hier steht die Platzpruefung. Ein entzogener Sitz zaehlt nicht mit, ein
// Der Grund, warum es die Vordertür sein muss und kein Knopf an der Zeile:
// hier steht die Platzprüfung. Ein entzogener Sitz zählt nicht mit, ein
// wiederaufgenommener schon.
$customer = Customer::factory()->create();
$user = $customer->ensureUser();
@ -607,8 +607,8 @@ it('weist die Wiederaufnahme ab, wenn kein Platz mehr frei ist', function () {
});
it('weist eine Adresse ab, die noch einen lebenden Platz hat', function () {
// Die Wiederaufnahme gilt NUR fuer entzogene Zeilen. Ein aktiver Sitz
// derselben Adresse bleibt ein Duplikat — sonst waere das Formular ein
// Die Wiederaufnahme gilt NUR für entzogene Zeilen. Ein aktiver Sitz
// derselben Adresse bleibt ein Duplikat — sonst wäre das Formular ein
// stiller Rollenwechsel an einer bestehenden Zeile.
$customer = Customer::factory()->create();
$user = $customer->ensureUser();
@ -626,10 +626,226 @@ it('weist eine Adresse ab, die noch einen lebenden Platz hat', function () {
expect($sitz->fresh()->role)->toBe('member');
});
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.
// --- K1: entziehen, während die Einladung noch in der Warteschlange steht.
// Der einzige Arbeiter teilt sich die Warteschlange mit der bezahlten
// Bereitstellung — das Fenster ist MINUTEN lang, nicht Millisekunden.
it('schickt die Sperre auch dann, wenn die Einladung noch unterwegs ist', function () {
// Der dritte Wächter-Fall in queueSync(): `none` und `failed` waren
// abgedeckt, `pending` nicht — und das ist der einzige Zustand, in dem
// noch ein Auftrag unterwegs ist. Ohne diesen Zweig kam an der Zeile
// „Entfernt" ein aktives, freigeschaltetes Konto in der Cloud heraus, und
// der Inhaber hatte im Portal keinen Weg mehr, die Sperre nachzuschicken:
// kein Wiederholen-Knopf (nicht `failed`), kein Sperren-Knopf (entzogen).
Queue::fake();
$customer = Customer::factory()->create();
$user = $customer->ensureUser();
Instance::factory()->for($customer)->create(['status' => 'active']);
$sitz = Seat::factory()->for($customer)->create([
'role' => 'member', 'status' => 'invited', 'nc_username' => 'anna@firma.tld',
'nc_state' => Seat::STATE_PENDING, 'nc_synced_at' => null,
]);
Livewire::actingAs($user)->test(Users::class)->call('revoke', $sitz->uuid);
Queue::assertPushed(SyncSeatToNextcloud::class, fn ($auftrag) => $auftrag->action === 'disable');
});
it('sperrt ein Konto, das erst nach dem Entziehen entsteht', function () {
// Der ganze Weg, rein durch Klicken erreichbar: einladen, entziehen
// solange der Auftrag wartet, Auftrag ausführen. Zwischen Absenden und
// Ausführen hat sich die Absicht geändert — der Auftrag darf dann nicht
// blind ausführen, was beim Absenden galt.
Queue::fake();
$pve = new FakeProxmoxClient;
// Der Benutzer existiert im Gast noch nicht: es läuft ein `user:add`,
// nicht nur ein `user:welcome` — der beschriebene Ablauf.
$pve->guestScript('user:info', 1);
app()->instance(ProxmoxClient::class, $pve);
$customer = Customer::factory()->create();
$user = $customer->ensureUser();
Instance::factory()->for($customer)->create(['status' => 'active', 'vmid' => 201, 'host_id' => Host::factory()]);
$sitz = Seat::factory()->for($customer)->create(['role' => 'member', 'email' => 'anna@firma.tld']);
$seite = Livewire::actingAs($user)->test(Users::class);
$seite->call('sendInvite', $sitz->uuid);
$seite->call('revoke', $sitz->uuid);
(new SyncSeatToNextcloud($sitz->uuid, 'invite'))->handle(app(NextcloudUsers::class));
expect($pve->guestRan('user:add'))->toBeTrue()
->and($pve->guestRan('user:disable'))->toBeTrue()
// Und die Zeile darf danach stimmen: entzogen UND gespiegelt.
->and($sitz->fresh()->status)->toBe('revoked')
->and($sitz->fresh()->nc_state)->toBe(Seat::STATE_SYNCED);
});
it('schickt bei einem inzwischen entzogenen Sitz kein Entsperren mit', function () {
// Eine Wiederaufnahme entsperrt das alte Konto. Wurde der Zugang
// inzwischen wieder entzogen, hiesse das: eine Tür aufmachen, um sie im
// selben Auftrag wieder zuzuziehen — im besten Fall folgenlos, im
// schlechteren ein offenes Zeitfenster.
$pve = new FakeProxmoxClient;
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([
'role' => 'member', 'status' => 'revoked', 'nc_username' => 'anna@firma.tld',
'nc_state' => Seat::STATE_PENDING, 'nc_synced_at' => now()->subYear(),
]);
(new SyncSeatToNextcloud($sitz->uuid, 'invite'))->handle(app(NextcloudUsers::class));
expect($pve->guestRan('user:enable'))->toBeFalse()
->and($pve->guestRan('user:disable'))->toBeTrue();
});
// --- K2: `retry()` kannte `enable` nicht.
it('wiederholt nach einem gescheiterten Entsperren nicht die Rolle', function () {
// Gesperrter Sitz → Entsperren → suspend() setzt `status` auf 'active' und
// schickt `enable`; der Gast antwortet nicht. Die Ableitung in retry()
// kannte `enable` nicht und wählte `role`: der Auftrag fuhr Gruppen und
// Quota, gelang, und die Zeile zeigte „Aktiv" — während `user:enable` nie
// geschickt wurde und der Mitarbeiter weiterhin nicht hineinkam. Der
// Wiederholen-Knopf war danach verschwunden.
Queue::fake();
$customer = Customer::factory()->create();
$user = $customer->ensureUser();
Instance::factory()->for($customer)->create(['status' => 'active']);
$sitz = Seat::factory()->for($customer)->create([
'role' => 'member', 'status' => 'active', 'nc_username' => 'anna@firma.tld',
'nc_state' => Seat::STATE_FAILED, 'nc_error' => 'guest_failed', 'nc_synced_at' => now(),
]);
Livewire::actingAs($user)->test(Users::class)->call('retry', $sitz->uuid);
Queue::assertPushed(SyncSeatToNextcloud::class, fn ($auftrag) => $auftrag->action === 'restore');
});
it('wiederholt auch nach einer Wiederaufnahme, deren Entsperren scheiterte', function () {
// Die zweite Tür, dieselbe Ursache: scheitert das `enable` INNERHALB des
// invite-Auftrags, blieb der Status 'invited' — die Ableitung wählte
// wieder `role`, das Konto blieb gesperrt, und die Zeile sagte
// „Eingeladen".
Queue::fake();
$customer = Customer::factory()->create();
$user = $customer->ensureUser();
Instance::factory()->for($customer)->create(['status' => 'active']);
$sitz = Seat::factory()->for($customer)->create([
'role' => 'member', 'status' => 'invited', 'nc_username' => 'anna@firma.tld',
'nc_state' => Seat::STATE_FAILED, 'nc_error' => 'guest_failed', 'nc_synced_at' => now()->subYear(),
]);
Livewire::actingAs($user)->test(Users::class)->call('retry', $sitz->uuid);
Queue::assertPushed(SyncSeatToNextcloud::class, fn ($auftrag) => $auftrag->action === 'restore');
});
it('macht beim Wiederholen einer offenen Zeile die Tür auf UND setzt die Rolle', function () {
// Warum `restore` und nicht `enable`: welcher der beiden Befehle beim
// letzten Mal scheiterte, weiss dieser Sitz nicht — und ein zweites Feld
// dafür wäre eine weitere Behauptung über die Cloud des Kunden, die
// irgendwann nicht mehr stimmt. Beide zu schicken ist billiger und
// ehrlicher als es zu raten.
$pve = new FakeProxmoxClient;
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([
'role' => 'member', 'nc_username' => 'anna@firma.tld', 'nc_synced_at' => now(),
]);
(new SyncSeatToNextcloud($sitz->uuid, 'restore'))->handle(app(NextcloudUsers::class));
expect($pve->guestRan('user:enable'))->toBeTrue()
->and($pve->guestRan('group:adduser'))->toBeTrue()
->and($sitz->fresh()->nc_state)->toBe(Seat::STATE_SYNCED);
});
it('lässt das Entsperren aus dem Sperren-Knopf ein reines Entsperren bleiben', function () {
// suspend() bleibt unangetastet: dort IST bekannt, was fehlt. Die Rolle
// wurde auch während der Sperre mitgeführt (setRole schickt `role`), also
// wäre sie hier fünf folgenlose Befehle im Gast — und ein folgenloser
// Befehl ist einer, den niemand mehr erklären kann, wenn er eines Tages
// doch etwas tut.
Queue::fake();
$customer = Customer::factory()->create();
$user = $customer->ensureUser();
Instance::factory()->for($customer)->create(['status' => 'active']);
$sitz = Seat::factory()->for($customer)->create([
'role' => 'member', 'status' => 'suspended', 'nc_username' => 'anna@firma.tld',
'nc_state' => Seat::STATE_SYNCED, 'nc_synced_at' => now(),
]);
Livewire::actingAs($user)->test(Users::class)->call('suspend', $sitz->uuid);
Queue::assertPushed(SyncSeatToNextcloud::class, fn ($auftrag) => $auftrag->action === 'enable');
});
// --- W2: der Inhaber-Sitz.
it('entzieht den Inhaber-Sitz auch dann nicht, wenn es einen zweiten gibt', function () {
// Die Wache in revoke() zählte nur owner-Sitze. Über setRole() liess sich
// ein zweiter owner erzeugen — danach griff sie nicht mehr, und der
// ECHTE Inhaber bekam `user:disable admin` samt
// `user:auth-tokens:delete admin` in seine eigene Cloud. Vor diesem Umbau
// war revoke() folgenlos; jetzt sperrt es wirklich aus.
Queue::fake();
$customer = Customer::factory()->create();
$user = $customer->ensureUser();
Instance::factory()->for($customer)->create(['status' => 'active']);
$inhaber = Seat::factory()->for($customer)->owner()->create([
'email' => $customer->email, 'nc_username' => 'admin',
'nc_state' => Seat::STATE_SYNCED, 'nc_synced_at' => now(),
]);
Seat::factory()->for($customer)->owner()->create(['email' => 'zweiter@firma.tld']);
Livewire::actingAs($user)->test(Users::class)->call('revoke', $inhaber->uuid);
expect($inhaber->fresh()->status)->toBe('active');
Queue::assertNothingPushed();
});
it('macht aus einem Mitarbeiter keinen Inhaber', function () {
// `setRole()` nahm jede Rolle aus Seat::ROLES an, also auch `owner`. Das
// Auswahlfeld bietet sie nicht an — die Livewire-Methode ist trotzdem
// öffentlich erreichbar, und genau das war der erste Schritt, mit dem
// sich der Inhaber selbst aussperren liess.
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('setRole', $sitz->uuid, 'owner');
expect($sitz->fresh()->role)->toBe('member');
});
it('nimmt dem Inhaber-Sitz seine Rolle auch dann nicht, wenn es einen zweiten gibt', function () {
// Dieselbe Klasse über die andere Tür: `setRole(<inhaberSitz>, 'member')`
// — das Admin-Konto verlöre seine Adminrechte im eigenen Gast.
Queue::fake();
$customer = Customer::factory()->create();
$user = $customer->ensureUser();
Instance::factory()->for($customer)->create(['status' => 'active']);
$inhaber = Seat::factory()->for($customer)->owner()->create([
'email' => $customer->email, 'nc_username' => 'admin',
'nc_state' => Seat::STATE_SYNCED, 'nc_synced_at' => now(),
]);
Seat::factory()->for($customer)->owner()->create(['email' => 'zweiter@firma.tld']);
Livewire::actingAs($user)->test(Users::class)->call('setRole', $inhaber->uuid, 'member');
expect($inhaber->fresh()->role)->toBe('owner');
Queue::assertNothingPushed();
});
it('nennt in keiner Datei unter app/ das Löschen eines Benutzers', function () {
// Testerzwungene Regel: kein Nextcloud-Benutzer wird je gelöscht, und
// keine Datei. Wer das ändern will, muss diese Prüfung anfassen und
// dabei über die Folgen stolpern.
$treffer = [];
foreach (Finder::create()->files()->in(app_path())->name('*.php') as $datei) {