Der Weg zurueck geht durch die Vordertuer, und ein Fehlschlag bleibt sichtbar
Der letzte Fix hat zwei eigene Loecher gerissen, beide dieselbe Klasse wie das, was er schliessen sollte: die Oberflaeche versicherte etwas, das im Gast nicht eingetreten war. Entzogen stand VOR dem Fehlschlag, also verschwanden Grund und Wiederholen-Knopf genau dort, wo sie am noetigsten sind: scheitert das disable, ist das Konto weiter offen, waehrend die Zeile "entzogen" sagt. Es gibt keinen Wiederholungslauf und keinen Abgleich, und diese Seite ist die einzige, die nc_state anzeigt. Beides steht jetzt nebeneinander. Und die Meldung empfahl einen neuen Sitz, den die Eindeutigkeit von (customer_id, email) unmoeglich machte. addSeat() nimmt einen entzogenen Sitz derselben Adresse wieder auf — durch dieselbe Platzpruefung wie jeder neue, denn das ist der Grund, warum es die Vordertuer sein muss und kein Knopf an der Zeile. Der Auftrag entsperrt dabei, was revoke() gesperrt hat; user:welcome tut das nicht. retry() bekommt die owner-Wache nach, die bisher nur am Knopf davor hing. Und der Kommentar in mount() behauptet keine Heilung mehr, die nicht stattfindet.feat/versandtakt
parent
a569d2215b
commit
02a5120b9c
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -81,45 +81,62 @@
|
|||
sichtbar geschieht. --}}
|
||||
<td class="px-4 py-3">
|
||||
@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. --}}
|
||||
<x-ui.badge status="suspended">{{ __('users.status_revoked') }}</x-ui.badge>
|
||||
<p class="mt-1 text-xs text-muted">{{ __('users.revoked_is_final') }}</p>
|
||||
<p class="mt-1 text-xs text-muted">{{ __('users.revoked_closed') }}</p>
|
||||
@elseif ($seat->nc_state === \App\Models\Seat::STATE_NONE)
|
||||
<x-ui.badge status="info">{{ __('users.state_none') }}</x-ui.badge>
|
||||
@elseif ($seat->nc_state === \App\Models\Seat::STATE_PENDING)
|
||||
<x-ui.badge status="info">{{ __('users.state_pending') }}</x-ui.badge>
|
||||
@elseif ($seat->nc_state === \App\Models\Seat::STATE_FAILED)
|
||||
<x-ui.badge status="failed">{{ __('users.state_failed') }}</x-ui.badge>
|
||||
{{-- Der Grund im Klartext, nicht der Code:
|
||||
'guest_failed' sagt einem Inhaber
|
||||
nichts, und ein Fehlschlag ohne Grund
|
||||
ist eine Sackgasse. --}}
|
||||
<p class="mt-1 text-xs text-muted">{{ __('users.error_'.($seat->nc_error ?: 'unexpected')) }}</p>
|
||||
<button type="button" wire:click="retry('{{ $seat->uuid }}')" class="mt-1 text-xs underline">
|
||||
{{ __('users.retry') }}
|
||||
</button>
|
||||
@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
|
||||
<x-ui.badge :status="$sb">{{ __('users.status_'.$seat->status) }}</x-ui.badge>
|
||||
@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)
|
||||
<div @class(['mt-1' => $seat->status === 'revoked'])>
|
||||
<x-ui.badge status="failed">{{ __('users.state_failed') }}</x-ui.badge>
|
||||
{{-- Der Grund im Klartext, nicht der
|
||||
Code: 'guest_failed' sagt einem
|
||||
Inhaber nichts, und ein Fehlschlag
|
||||
ohne Grund ist eine Sackgasse. --}}
|
||||
<p class="mt-1 text-xs text-muted">{{ __('users.error_'.($seat->nc_error ?: 'unexpected')) }}</p>
|
||||
@if ($seat->role !== 'owner')
|
||||
<button type="button" wire:click="retry('{{ $seat->uuid }}')" class="mt-1 text-xs underline">
|
||||
{{ __('users.retry') }}
|
||||
</button>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center justify-end gap-1.5">
|
||||
@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. --}}
|
||||
<span class="cursor-help text-xs text-muted" title="{{ __('users.revoked_is_final') }}">{{ __('users.revoked_final') }}</span>
|
||||
<span class="cursor-help text-xs text-muted" title="{{ __('users.revoked_closed') }}">{{ __('users.revoked_final') }}</span>
|
||||
@else
|
||||
{{-- Einladen ist der zweite Vorgang und
|
||||
hat deshalb seinen eigenen Knopf. Er
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue