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
parent
87ecb4d064
commit
1fd5d5f8d2
|
|
@ -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 sä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 fü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 lä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'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 hä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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue