diff --git a/app/Livewire/Users.php b/app/Livewire/Users.php index 50f3371..356f887 100644 --- a/app/Livewire/Users.php +++ b/app/Livewire/Users.php @@ -64,13 +64,20 @@ class Users extends Component * behielte sonst fuer immer einen owner-Sitz ohne Verknuepfung. Der * Aufruf ist folgenlos, solange es kein Konto gibt. * - * Die Bedingung haengt an `nc_username`, NICHT an `nc_state`: `nc_state` - * sagt nur, wie der letzte Versuch ausging. Stuende hier - * `=== STATE_NONE`, waere ein Sitz nach einem einzigen fehlgeschlagenen - * Auftrag fuer immer unverknuepft — und ein spaeterer Versuch legte in - * der Nextcloud ein ZWEITES Konto in der Gruppe `admin` an, neben dem - * echten. `nc_username` ist die Angabe, die genau einmal gesetzt wird - * und danach steht. + * Die Bedingung haengt an `nc_username`, weil das die Angabe ist, um + * die es geht: genau einmal gesetzt, danach unveraenderlich. 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 + * `nc_username`, und dann greift sie nie. + * + * Beschaedigt 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 + * Eingriff an der Zeile — nicht diese Bedingung. * * Die Wanderung aus Aufgabe 5 tut dasselbe fuer den Bestand — sie fuehrt * ihre eigene, eingefrorene Fassung. Hier gilt die laufende. @@ -88,6 +95,14 @@ class Users extends Component * Schickt ausdruecklich NICHTS los und laesst `nc_state` auf `none`: ein * Inhaber soll sein Team vorbereiten koennen, ohne dass jemand eine Mail * bekommt. Erst `sendInvite()` erzeugt einen Benutzer im Gast. + * + * Und es ist zugleich die Vordertuer zurueck: 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 + * 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. */ public function addSeat(): void { @@ -102,14 +117,35 @@ class Users extends Component // the last free seat can't both pass (lock the customer row). $result = DB::transaction(function () use ($customer, $data) { $locked = Customer::query()->whereKey($customer->id)->lockForUpdate()->first(); + $vorhanden = $locked->seats()->where('email', $data['inviteEmail'])->first(); - if ($locked->seats()->where('email', $data['inviteEmail'])->exists()) { + if ($vorhanden !== null && $vorhanden->status !== 'revoked') { 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, + // genau wie beim Anlegen. if ($this->usedSeats($locked) >= $this->seatLimit($locked)) { return 'limit'; } + if ($vorhanden !== null) { + // `nc_username` bleibt, wie er ist: Nextcloud kann Benutzer + // nicht umbenennen, und es ist dieselbe Person. `nc_state` + // ebenfalls — was im Gast steht, aendert sich durch das + // Anlegen nie, das ist der ganze Sinn der Trennung. + $vorhanden->update([ + 'name' => $data['inviteName'] ?: $vorhanden->name, + 'role' => $data['inviteRole'], + 'status' => 'invited', + 'invited_at' => now(), + ]); + + return 'reinstated'; + } + $locked->seats()->create([ 'email' => $data['inviteEmail'], 'name' => $data['inviteName'] ?: null, @@ -134,7 +170,7 @@ class Users extends Component $this->reset('inviteEmail', 'inviteName', 'inviteRole'); $this->inviteRole = 'member'; - $this->dispatch('notify', message: __('users.added')); + $this->dispatch('notify', message: __($result === 'reinstated' ? 'users.reinstated' : 'users.added')); } /** @@ -163,7 +199,7 @@ class Users extends Component // 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') { - $this->dispatch('notify', message: __('users.revoked_is_final')); + $this->dispatch('notify', message: __('users.revoked_closed')); return; } @@ -232,6 +268,23 @@ class Users extends Component return; } + // Dieselbe Wache wie in sendInvite(), und aus demselben Grund: an einem + // beschaedigten Inhaber-Sitz (`failed`, nie gespiegelt) waehlte 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 + // Knopf davor, nicht der Auftragsweg dahinter. + if ($seat->role === 'owner') { + $this->dispatch('notify', message: __('users.owner_not_invitable')); + + 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 + // 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 @@ -369,7 +422,7 @@ class Users extends Component // anmelden, und er zaehlte wieder gegen die Platzgrenze, ohne dass sie // hier jemals geprueft wird. if ($seat->status === 'revoked') { - $this->dispatch('notify', message: __('users.revoked_is_final')); + $this->dispatch('notify', message: __('users.revoked_closed')); return; } diff --git a/app/Provisioning/Jobs/SyncSeatToNextcloud.php b/app/Provisioning/Jobs/SyncSeatToNextcloud.php index c718f4f..1458447 100644 --- a/app/Provisioning/Jobs/SyncSeatToNextcloud.php +++ b/app/Provisioning/Jobs/SyncSeatToNextcloud.php @@ -101,12 +101,25 @@ class SyncSeatToNextcloud implements ShouldQueue */ private function invite(NextcloudUsers $users, Instance $instance, Seat $seat): bool { + // 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 + // sich auf ein folgenloses `user:enable` zu verlassen. + $wiederaufnahme = $seat->nc_synced_at !== null; + if (! $users->invite($instance, $seat)) { return false; } $seat->forceFill(['nc_synced_at' => now()])->save(); + if ($wiederaufnahme && ! $users->enable($instance, $seat)) { + return false; + } + return $users->applyRole($instance, $seat); } diff --git a/lang/de/users.php b/lang/de/users.php index 284c069..6db42c1 100644 --- a/lang/de/users.php +++ b/lang/de/users.php @@ -78,14 +78,17 @@ return [ 'error_guest_failed' => 'Die Cloud hat die Änderung nicht angenommen.', 'error_unexpected' => 'Unerwarteter Fehler. Bitte noch einmal versuchen.', - // Entzogen ist endgültig. Vorher löschte revoke() die Zeile, und die Frage - // stellte sich nie; heute bleibt sie stehen, und ohne diese beiden Sätze - // sähe sie aus wie ein Zugang, den man wieder aufmachen kann. - 'revoked_final' => 'Endgültig entzogen', - 'revoked_is_final' => 'Dieser Zugang wurde entzogen und kann nicht wieder geöffnet werden. Legen Sie bei Bedarf einen neuen an.', + // An der ZEILE gibt es keinen Weg zurück — das war die versehentliche + // Wiederbelebung mit zwei Klicks, vorbei an der Platzgrenze. Über das + // Formular oben schon: dieselbe Adresse, dieselbe Prüfung wie jeder neue + // Platz. „Endgültig" wäre deshalb gelogen, und ein Satz, der eine Sackgasse + // behauptet, wo eine Tür ist, kostet mehr als eine offene Lücke. + 'revoked_final' => 'Zugang entzogen', + 'revoked_closed' => 'Dieser Zugang ist geschlossen. Über das Formular oben können Sie dieselbe Adresse wieder aufnehmen — sie zählt dann wieder gegen Ihre Plätze.', 'owner_not_invitable' => 'Der Zugang des Inhabers wird beim Aufbau der Cloud angelegt und kann hier nicht eingeladen werden.', 'added' => 'Benutzer angelegt. Die Einladung verschicken Sie mit „Einladen".', + 'reinstated' => 'Zugang wieder aufgenommen. Die Einladung verschicken Sie mit „Einladen".', 'invite_sent' => 'Einladung verschickt. Der Mitarbeiter bekommt einen Link, an dem er sein Passwort selbst setzt.', 'too_many_invites' => 'Zu viele Einladungen — in :minutes Minuten wieder möglich.', 'revoked' => 'Benutzer entfernt.', diff --git a/lang/en/users.php b/lang/en/users.php index 547c832..8180320 100644 --- a/lang/en/users.php +++ b/lang/en/users.php @@ -77,14 +77,17 @@ return [ 'error_guest_failed' => 'The cloud did not accept the change.', 'error_unexpected' => 'Unexpected error. Please try again.', - // Revoked is final. revoke() used to delete the row and the question never - // came up; today it stays, and without these two sentences it would look - // like access somebody can open again. - 'revoked_final' => 'Revoked for good', - 'revoked_is_final' => 'This access was revoked and cannot be reopened. Add a new seat if you need one.', + // There is no way back from the ROW — that was the accidental revival in + // two clicks, bypassing the seat limit. Through the form above there is: + // same address, same check as any new seat. "Final" would therefore be a + // lie, and a sentence claiming a dead end where there is a door costs more + // than an open gap. + 'revoked_final' => 'Access revoked', + 'revoked_closed' => 'This access is closed. Use the form above to take the same address back on — it then counts against your seats again.', 'owner_not_invitable' => 'The owner\'s account is created when the cloud is built and cannot be invited from here.', 'added' => 'User added. Send the invitation with "Invite".', + 'reinstated' => 'Access taken back on. Send the invitation with "Invite".', 'invite_sent' => 'Invitation sent. They get a link to set their own password.', 'too_many_invites' => 'Too many invitations — possible again in :minutes minutes.', 'revoked' => 'User removed.', diff --git a/resources/views/livewire/users.blade.php b/resources/views/livewire/users.blade.php index 2bd9efb..0b5fad6 100644 --- a/resources/views/livewire/users.blade.php +++ b/resources/views/livewire/users.blade.php @@ -81,45 +81,62 @@ sichtbar geschieht. --}} @if ($seat->status === 'revoked') - {{-- Entzogen geht allem anderen vor und ist - endgueltig. Ein „wird eingerichtet …" - oder ein „Nochmal versuchen" an dieser - Zeile las sich wie ein Weg zurueck — - den es nicht gibt und nicht geben - soll. --}} + {{-- Entzogen geht dem Zustand vor: ein + „wird eingerichtet …" an einer + geschlossenen Zeile las sich wie ein + Weg zurueck, den es an DIESER Zeile + nicht gibt. --}} {{ __('users.status_revoked') }} -

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

+

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

@elseif ($seat->nc_state === \App\Models\Seat::STATE_NONE) {{ __('users.state_none') }} @elseif ($seat->nc_state === \App\Models\Seat::STATE_PENDING) {{ __('users.state_pending') }} - @elseif ($seat->nc_state === \App\Models\Seat::STATE_FAILED) - {{ __('users.state_failed') }} - {{-- Der Grund im Klartext, nicht der Code: - 'guest_failed' sagt einem Inhaber - nichts, und ein Fehlschlag ohne Grund - ist eine Sackgasse. --}} -

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

- - @else - @php $sb = ['active' => 'active', 'invited' => 'provisioning', 'revoked' => 'suspended'][$seat->status] ?? 'info'; @endphp + @elseif ($seat->nc_state !== \App\Models\Seat::STATE_FAILED) + @php $sb = ['active' => 'active', 'invited' => 'provisioning'][$seat->status] ?? 'info'; @endphp {{ __('users.status_'.$seat->status) }} @endif + + {{-- Der Fehlschlag steht NEBEN dem Entzogen, + nicht dahinter. Sonst verschwaende genau + dort die Rueckfahrkarte, wo sie am + noetigsten ist: scheitert das `disable`, + ist das Konto im Gast weiter offen, + waehrend die Zeile „entzogen" sagt — kein + Wiederholungslauf (tries = 1), kein + Abgleich im Zeitplan, und diese Seite ist + die einzige Stelle im Repo, die nc_state + ueberhaupt anzeigt. --}} + @if ($seat->nc_state === \App\Models\Seat::STATE_FAILED) +
$seat->status === 'revoked'])> + {{ __('users.state_failed') }} + {{-- Der Grund im Klartext, nicht der + Code: 'guest_failed' sagt einem + Inhaber nichts, und ein Fehlschlag + ohne Grund ist eine Sackgasse. --}} +

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

+ @if ($seat->role !== 'owner') + + @endif +
+ @endif
@if ($seat->status === 'revoked') - {{-- Kein Knopf. Kein Wiedereinsetzen: der - richtige Weg zurueck ist ein NEUER - Sitz, und der geht durch die - Platzpruefung. Gesagt statt leer + {{-- Kein Knopf an der Zeile. Der Weg + zurueck geht durch die Vordertuer: das + Anlegen-Formular oben, mit derselben + Adresse — und damit durch die + Platzpruefung, die es an der Zeile + nicht gaebe. Gesagt statt leer gelassen, wie bei der Inhaber-Zeile — eine leere Zelle liesse jemanden nach einem Knopf suchen, der absichtlich fehlt. --}} - {{ __('users.revoked_final') }} + {{ __('users.revoked_final') }} @else {{-- Einladen ist der zweite Vorgang und hat deshalb seinen eigenen Knopf. Er diff --git a/tests/Feature/Seats/SyncSeatToNextcloudTest.php b/tests/Feature/Seats/SyncSeatToNextcloudTest.php index cb7987c..b57ebae 100644 --- a/tests/Feature/Seats/SyncSeatToNextcloudTest.php +++ b/tests/Feature/Seats/SyncSeatToNextcloudTest.php @@ -229,6 +229,10 @@ it('vermerkt ein angelegtes Konto, auch wenn die Rolle danach scheitert', functi // liesse ein spaeteres Entziehen die Sperre weg, weil die Seite den Sitz // fuer nie angekommen hielte. Der Zustand bleibt trotzdem 'failed'. $pve = new FakeProxmoxClient; + // user:info auf 1, sonst haelt invite() den Benutzer fuer vorhanden und + // schickt nur `user:welcome` — dann liefe hier gar kein `user:add`, und der + // Ablauf waere ein anderer als der beschriebene. + $pve->guestScript('user:info', 1); $pve->guestScript('group:adduser', 1); app()->instance(ProxmoxClient::class, $pve); $customer = Customer::factory()->create(); @@ -237,10 +241,47 @@ it('vermerkt ein angelegtes Konto, auch wenn die Rolle danach scheitert', functi (new SyncSeatToNextcloud($sitz->uuid, 'invite'))->handle(app(NextcloudUsers::class)); - expect($sitz->fresh()->nc_state)->toBe(Seat::STATE_FAILED) + expect($pve->guestRan('user:add'))->toBeTrue() + ->and($sitz->fresh()->nc_state)->toBe(Seat::STATE_FAILED) ->and($sitz->fresh()->nc_synced_at)->not->toBeNull(); }); +it('macht die Sperre beim Wiederaufnehmen rueckgaengig', 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. + $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([ + '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'))->toBeTrue() + ->and($sitz->fresh()->nc_state)->toBe(Seat::STATE_SYNCED); +}); + +it('schickt bei einer ersten Einladung kein Entsperren mit', function () { + // Ein frisch angelegtes Konto ist nicht gesperrt. Ein `user:enable` waere + // hier folgenlos — und ein folgenloser Befehl im Gast ist einer, den + // niemand mehr erklaeren kann, wenn er eines Tages doch etwas tut. + $pve = new FakeProxmoxClient; + $pve->guestScript('user:info', 1); + app()->instance(ProxmoxClient::class, $pve); + $customer = Customer::factory()->create(); + Instance::factory()->for($customer)->create(['status' => 'active', 'vmid' => 201, 'host_id' => Host::factory()]); + $sitz = Seat::factory()->for($customer)->create(['nc_username' => 'anna@firma.tld']); + + (new SyncSeatToNextcloud($sitz->uuid, 'invite'))->handle(app(NextcloudUsers::class)); + + expect($pve->guestRan('user:enable'))->toBeFalse() + ->and($sitz->fresh()->nc_state)->toBe(Seat::STATE_SYNCED); +}); + it('spiegelt eine Rollenaenderung in die Nextcloud', function () { Queue::fake(); $customer = Customer::factory()->create(); @@ -345,7 +386,7 @@ it('sagt an einer entzogenen Zeile „entzogen", nicht „noch nicht eingeladen" Livewire::actingAs($user)->test(Users::class) ->assertSee(__('users.status_revoked')) - ->assertSee(__('users.revoked_is_final')) + ->assertSee(__('users.revoked_closed')) ->assertDontSee(__('users.state_none')); }); @@ -368,16 +409,20 @@ it('laedt den Inhaber nicht zu seiner eigenen Cloud ein', function () { }); it('verknuepft den Inhaber-Sitz beim Besuch, auch wenn die Instanz spaeter dazukommt', function () { - // Wer /users oeffnet, bevor die Bereitstellung durch ist, hatte danach - // einen owner-Sitz ohne Verknuepfung. Haenge die Bedingung an nc_state, - // reicht EIN fehlgeschlagener Auftrag, damit sie nie wieder greift — der - // Sitz bliebe fuer immer unverknuepft. Deshalb steht hier ein Sitz auf - // 'failed' und wird trotzdem geheilt. + // /users verlangt keine fertige Instanz. Wer die Seite oeffnet, 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. + // Der Aufruf muss deshalb bei JEDEM Besuch stattfinden, nicht nur beim + // ersten. $customer = Customer::factory()->create(); $user = $customer->ensureUser(); - $sitz = Seat::factory()->for($customer)->owner()->create([ - 'nc_state' => Seat::STATE_FAILED, 'nc_error' => 'no_instance', - ]); + + // Erster Besuch, noch ohne Instanz: mount() legt den owner-Sitz an, findet + // aber kein Konto zum Verknuepfen. + Livewire::actingAs($user)->test(Users::class)->assertOk(); + $sitz = $customer->seats()->where('role', 'owner')->firstOrFail(); + expect($sitz->nc_username)->toBeNull(); Instance::factory()->for($customer)->create(['status' => 'active', 'nc_admin_ref' => 'admin']); @@ -387,6 +432,134 @@ 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 + // entzieht, der `disable`-Auftrag scheitert (Gast aus, Tunnel weg), und das + // Konto im Gast ist WEITER OFFEN, waehrend 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. + $customer = Customer::factory()->create(); + $user = $customer->ensureUser(); + Instance::factory()->for($customer)->create(['status' => 'active']); + $sitz = Seat::factory()->for($customer)->create([ + 'role' => 'member', 'status' => 'revoked', 'nc_username' => 'anna@firma.tld', + 'nc_state' => Seat::STATE_FAILED, 'nc_error' => 'guest_failed', 'nc_synced_at' => now(), + ]); + + Livewire::actingAs($user)->test(Users::class) + ->assertSee(__('users.state_failed')) + ->assertSee(__('users.error_guest_failed')) + ->assertSee("retry('{$sitz->uuid}')", escape: false) + // Und die Zeile sagt trotzdem, dass der Zugang geschlossen ist. + ->assertSee(__('users.status_revoked')); +}); + +it('wiederholt an einem entzogenen Sitz das Sperren, nie das Einladen', function () { + Queue::fake(); + $customer = Customer::factory()->create(); + $user = $customer->ensureUser(); + Instance::factory()->for($customer)->create(['status' => 'active']); + $sitz = Seat::factory()->for($customer)->create([ + 'role' => 'member', 'status' => 'revoked', '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 === 'disable'); + expect($sitz->fresh()->status)->toBe('revoked'); +}); + +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 + // `invite` und legte damit genau das an, was die Wache in sendInvite() + // verhindern soll — ein ZWEITES Konto in der Gruppe `admin`. + Queue::fake(); + $customer = Customer::factory()->create(); + $user = $customer->ensureUser(); + Instance::factory()->for($customer)->create(['status' => 'active']); + $sitz = Seat::factory()->for($customer)->owner()->create([ + 'nc_state' => Seat::STATE_FAILED, 'nc_error' => 'guest_failed', + ]); + + Livewire::actingAs($user)->test(Users::class)->call('retry', $sitz->uuid); + + Queue::assertNothingPushed(); + 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 + // Adresse an der Eindeutigkeit von (customer_id, email) ab — die Meldung + // empfahl damit einen Weg, den es nicht gab. + Queue::fake(); + $customer = Customer::factory()->create(); + $user = $customer->ensureUser(); + Instance::factory()->for($customer)->create(['status' => 'active']); + $sitz = Seat::factory()->for($customer)->create([ + 'email' => 'anna@firma.tld', 'role' => 'member', 'status' => 'revoked', + 'nc_username' => 'anna@firma.tld', 'nc_state' => Seat::STATE_SYNCED, 'nc_synced_at' => now(), + ]); + + Livewire::actingAs($user)->test(Users::class) + ->set('inviteEmail', 'anna@firma.tld') + ->set('inviteRole', 'readonly') + ->call('addSeat') + ->assertHasNoErrors(); + + // Dieselbe Zeile, kein Duplikat — und der Anmeldename bleibt, wie er ist: + // Nextcloud kann Benutzer nicht umbenennen, und es ist dieselbe Person. + expect($customer->seats()->where('email', 'anna@firma.tld')->count())->toBe(1) + ->and($sitz->fresh()->status)->toBe('invited') + ->and($sitz->fresh()->role)->toBe('readonly') + ->and($sitz->fresh()->nc_username)->toBe('anna@firma.tld'); + + // Angelegt ist nicht eingeladen — auch beim Wiederaufnehmen nicht. + Queue::assertNothingPushed(); +}); + +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 + // wiederaufgenommener schon. + $customer = Customer::factory()->create(); + $user = $customer->ensureUser(); + Instance::factory()->for($customer)->create(['status' => 'active']); + Seat::factory()->for($customer)->count(5)->create(['role' => 'member']); + $sitz = Seat::factory()->for($customer)->create([ + 'email' => 'anna@firma.tld', 'role' => 'member', 'status' => 'revoked', + ]); + + Livewire::actingAs($user)->test(Users::class) + ->set('inviteEmail', 'anna@firma.tld') + ->call('addSeat') + ->assertHasErrors(['inviteEmail']); + + expect($sitz->fresh()->status)->toBe('revoked'); +}); + +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 + // stiller Rollenwechsel an einer bestehenden Zeile. + $customer = Customer::factory()->create(); + $user = $customer->ensureUser(); + Instance::factory()->for($customer)->create(['status' => 'active']); + $sitz = Seat::factory()->for($customer)->create([ + 'email' => 'anna@firma.tld', 'role' => 'member', 'status' => 'active', + ]); + + Livewire::actingAs($user)->test(Users::class) + ->set('inviteEmail', 'anna@firma.tld') + ->set('inviteRole', 'admin') + ->call('addSeat') + ->assertHasErrors(['inviteEmail']); + + 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