update([ 'address' => 'noreply@clupilot.cloud', 'password' => 'geheim', 'active' => true, ]); } it('schreibt den Erfolg an den Sitz', function () { app()->instance(ProxmoxClient::class, new FakeProxmoxClient); $customer = Customer::factory()->create(); // host_id, weil die host-Beziehung sonst null bliebe: der Auftrag bricht // dann mit 'no_instance' ab und käme nie bis zum Gast — dieselbe Falle wie // in NextcloudUsersTest::gastBereit(). Instance::factory()->for($customer)->create(['status' => 'active', 'vmid' => 201, 'host_id' => Host::factory()]); $sitz = Seat::factory()->for($customer)->create([ 'email' => 'anna@firma.tld', 'nc_username' => 'anna@firma.tld', 'nc_state' => Seat::STATE_PENDING, ]); (new SyncSeatToNextcloud($sitz->uuid, 'invite'))->handle(app(NextcloudUsers::class)); expect($sitz->fresh()->nc_state)->toBe(Seat::STATE_SYNCED) ->and($sitz->fresh()->nc_error)->toBeNull() ->and($sitz->fresh()->nc_synced_at)->not->toBeNull(); }); it('schreibt den Fehlschlag mit Grund an den Sitz', function () { $pve = new FakeProxmoxClient; $pve->guestThrows[201] = new RuntimeException('guest agent unreachable'); 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)); // Nicht nur „irgendein Fehler": der Grund muss der des unerreichbaren // Gastes sein, nicht der der fehlenden Instanz — sonst liefe der Test auch // dann grün, wenn die Instanz gar nicht gefunden wurde. expect($sitz->fresh()->nc_state)->toBe(Seat::STATE_FAILED) ->and($sitz->fresh()->nc_error)->toBe('guest_failed'); }); it('sagt es am Sitz, wenn es gar keine erreichbare Instanz gibt', function () { // Der Kunde hat keine laufende Instanz — es gibt nichts zu spiegeln, und // der Inhaber muss den Unterschied zu „der Gast hat abgelehnt" lesen // können, weil nur das eine von beiden er selbst beheben kann. $customer = Customer::factory()->create(); $sitz = Seat::factory()->for($customer)->create(['nc_username' => 'anna@firma.tld']); (new SyncSeatToNextcloud($sitz->uuid, 'invite'))->handle(app(NextcloudUsers::class)); expect($sitz->fresh()->nc_state)->toBe(Seat::STATE_FAILED) ->and($sitz->fresh()->nc_error)->toBe('no_instance'); }); 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'); expect($auftrag->connection)->toBe('provisioning') ->and($auftrag->queue)->toBe('provisioning'); }); it('legt beim Anlegen KEINEN Nextcloud-Benutzer an', function () { // Anlegen ist nicht Einladen. Der Inhaber soll sein Team vorbereiten // können, ohne dass jemand eine Mail bekommt. Queue::fake(); $customer = Customer::factory()->create(); $user = $customer->ensureUser(); Instance::factory()->for($customer)->create(['status' => 'active']); Livewire::actingAs($user)->test(Users::class) ->set('inviteEmail', 'anna@firma.tld') ->set('inviteName', 'Anna') ->call('addSeat'); Queue::assertNothingPushed(); expect(Seat::where('email', 'anna@firma.tld')->first()->nc_state)->toBe(Seat::STATE_NONE); }); it('schickt erst beim Einladen einen Auftrag los', function () { Queue::fake(); mailversandEingerichtet(); $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('sendInvite', $sitz->uuid); Queue::assertPushed(SyncSeatToNextcloud::class); expect($sitz->fresh()->nc_state)->toBe(Seat::STATE_PENDING) ->and($sitz->fresh()->nc_username)->toBe($sitz->email); }); it('hält den Benutzernamen fest, auch wenn die Mailadresse sich ändert', function () { // Nextcloud kann Benutzer nicht umbenennen. Ein Sitz, dessen Adresse sich // später ändert, behält seinen Anmeldenamen. Queue::fake(); mailversandEingerichtet(); $customer = Customer::factory()->create(); $user = $customer->ensureUser(); Instance::factory()->for($customer)->create(['status' => 'active']); $sitz = Seat::factory()->for($customer)->create(['email' => 'alt@firma.tld', 'nc_username' => 'alt@firma.tld']); $sitz->update(['email' => 'neu@firma.tld']); Livewire::actingAs($user)->test(Users::class)->call('sendInvite', $sitz->uuid); expect($sitz->fresh()->nc_username)->toBe('alt@firma.tld'); }); it('weist die elfte Einladung derselben Stunde ab und sagt die Restzeit', function () { Queue::fake(); mailversandEingerichtet(); $customer = Customer::factory()->create(); $user = $customer->ensureUser(); Instance::factory()->for($customer)->create(['status' => 'active']); $sitze = Seat::factory()->for($customer)->count(11)->create(['role' => 'member']); $seite = Livewire::actingAs($user)->test(Users::class); foreach ($sitze->take(10) as $sitz) { $seite->call('sendInvite', $sitz->uuid); } $seite->call('sendInvite', $sitze->last()->uuid); // Keine stumme Verweigerung: die Meldung nennt die echte Restzeit. Auf die // Zahl geprüft statt auf den Satz — ein Test, der denselben __()-Aufruf // nachrechnet wie die Implementierung, würde auch eine fehlende Restzeit // durchwinken (siehe R19). $seite->assertDispatched( 'notify', fn ($ereignis, $daten) => str_contains($daten['message'], '60'), ); expect($sitze->last()->fresh()->nc_state)->toBe(Seat::STATE_NONE); Queue::assertPushed(SyncSeatToNextcloud::class, 10); }); 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(); mailversandEingerichtet(); $customer = Customer::factory()->create(); $user = $customer->ensureUser(); Instance::factory()->for($customer)->create(['status' => 'active']); $sitz = Seat::factory()->for($customer)->create(['role' => 'member']); $seite = Livewire::actingAs($user)->test(Users::class); foreach (range(1, 4) as $ignoriert) { $seite->call('sendInvite', $sitz->uuid); } Queue::assertPushed(SyncSeatToNextcloud::class, 3); }); it('entzieht, ohne die Zeile zu löschen', function () { Queue::fake(); $customer = Customer::factory()->create(); $user = $customer->ensureUser(); Instance::factory()->for($customer)->create(['status' => 'active']); // Ein Sitz, der wirklich in der Nextcloud steht: nc_synced_at ist die // Angabe, an der die Seite entscheidet, ob es dort etwas zu sperren gibt. $sitz = Seat::factory()->for($customer)->create([ 'role' => 'member', 'nc_username' => 'anna@firma.tld', 'nc_state' => Seat::STATE_SYNCED, 'nc_synced_at' => now(), ]); Livewire::actingAs($user)->test(Users::class)->call('revoke', $sitz->uuid); // Der Datensatz bleibt. Ein Fehlgriff im Userpanel darf die Arbeit eines // Menschen nicht vernichten — und in einem Produkt, das mit // 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 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. 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('revoke', $sitz->uuid); Queue::assertNothingPushed(); expect($sitz->fresh()->status)->toBe('revoked') ->and($sitz->fresh()->nc_state)->toBe(Seat::STATE_NONE); }); it('sperrt nichts, was in der Nextcloud nie angekommen ist', function () { // Einladung losgeschickt, Gast war unerreichbar: der Sitz trägt schon // einen Anmeldenamen, in der Nextcloud existiert aber nichts. Ein // `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(); Instance::factory()->for($customer)->create(['status' => 'active']); $sitz = Seat::factory()->for($customer)->create([ 'role' => 'member', 'nc_username' => 'anna@firma.tld', 'nc_state' => Seat::STATE_FAILED, 'nc_error' => 'no_instance', 'nc_synced_at' => null, ]); Livewire::actingAs($user)->test(Users::class)->call('revoke', $sitz->uuid); Queue::assertNothingPushed(); expect($sitz->fresh()->status)->toBe('revoked'); }); it('vermerkt ein angelegtes Konto, auch wenn die Rolle danach scheitert', function () { // 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 hält invite() den Benutzer für vorhanden und // schickt nur `user:welcome` — dann liefe hier gar kein `user:add`, und der // Ablauf wäre ein anderer als der beschriebene. $pve->guestScript('user:info', 1); $pve->guestScript('group:adduser', 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: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 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` bekäme er eine // Willkommensmail zu einer Tür, 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` wäre // hier folgenlos — und ein folgenloser Befehl im Gast ist einer, den // 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); $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 Rollenänderung in die Nextcloud', 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', 'nc_username' => 'anna@firma.tld', 'nc_state' => Seat::STATE_SYNCED, 'nc_synced_at' => now(), ]); Livewire::actingAs($user)->test(Users::class)->call('setRole', $sitz->uuid, 'readonly'); Queue::assertPushed(SyncSeatToNextcloud::class, fn ($auftrag) => $auftrag->action === 'role'); expect($sitz->fresh()->nc_state)->toBe(Seat::STATE_PENDING); }); it('gibt dem Inhaber nach einem Fehlschlag einen zweiten Versuch', 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', 'nc_username' => 'anna@firma.tld', 'nc_state' => Seat::STATE_FAILED, 'nc_error' => 'guest_failed', ]); Livewire::actingAs($user)->test(Users::class)->call('retry', $sitz->uuid); // Der Grund verschwindet mit dem neuen Versuch — ein stehengebliebener // alter Fehlertext neben „wird eingerichtet …" wäre die Attrappe zurück. Queue::assertPushed(SyncSeatToNextcloud::class, fn ($auftrag) => $auftrag->action === 'invite'); expect($sitz->fresh()->nc_state)->toBe(Seat::STATE_PENDING) ->and($sitz->fresh()->nc_error)->toBeNull(); }); 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, 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(); 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_SYNCED, 'nc_synced_at' => now(), ]); $seite = Livewire::actingAs($user)->test(Users::class); $seite->call('suspend', $sitz->uuid); $seite->call('suspend', $sitz->uuid); expect($sitz->fresh()->status)->toBe('revoked'); Queue::assertNothingPushed(); }); 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(); mailversandEingerichtet(); $customer = Customer::factory()->create(); $user = $customer->ensureUser(); Instance::factory()->for($customer)->create(['status' => 'active']); $sitz = Seat::factory()->for($customer)->create(['role' => 'member', 'status' => 'revoked']); Livewire::actingAs($user)->test(Users::class)->call('sendInvite', $sitz->uuid); Queue::assertNothingPushed(); expect($sitz->fresh()->nc_state)->toBe(Seat::STATE_NONE) ->and($sitz->fresh()->nc_username)->toBeNull() ->and($sitz->fresh()->status)->toBe('revoked'); }); it('zeigt an einer entzogenen Zeile keinen Handlungsknopf mehr', function () { // Ein Knopf, den der Server ohnehin abweist, ist eine Einladung zum // 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']); $sitz = Seat::factory()->for($customer)->create(['role' => 'member', 'status' => 'revoked']); Livewire::actingAs($user)->test(Users::class) ->assertSee(__('users.revoked_final')) ->assertDontSee("sendInvite('{$sitz->uuid}')", escape: false) ->assertDontSee("suspend('{$sitz->uuid}')", escape: false) ->assertDontSee("retry('{$sitz->uuid}')", escape: false) ->assertDontSee('confirm-revoke-seat', escape: false) ->assertDontSee('edit-seat', escape: false); }); it('sagt an einer entzogenen Zeile „entzogen", nicht „noch nicht eingeladen"', function () { // 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 läse sich wie ein Weg zurück. $customer = Customer::factory()->create(); $user = $customer->ensureUser(); Instance::factory()->for($customer)->create(['status' => 'active']); Seat::factory()->for($customer)->create(['role' => 'member', 'status' => 'revoked']); Livewire::actingAs($user)->test(Users::class) ->assertSee(__('users.status_revoked')) ->assertSee(__('users.revoked_closed')) ->assertDontSee(__('users.state_none')); }); it('lädt den Inhaber nicht zu seiner eigenen Cloud ein', function () { // Dieses Konto legt die Bereitstellung an (CreateCustomerAdmin). Ein // 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(); mailversandEingerichtet(); $customer = Customer::factory()->create(); $user = $customer->ensureUser(); Instance::factory()->for($customer)->create(['status' => 'active']); $sitz = Seat::factory()->for($customer)->owner()->create(); Livewire::actingAs($user)->test(Users::class)->call('sendInvite', $sitz->uuid); Queue::assertNothingPushed(); expect($sitz->fresh()->nc_username)->toBeNull() ->and($sitz->fresh()->nc_state)->toBe(Seat::STATE_NONE); }); 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 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 Verknüpfen. 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']); Livewire::actingAs($user)->test(Users::class)->assertOk(); expect($sitz->fresh()->nc_username)->toBe('admin') ->and($sitz->fresh()->nc_state)->toBe(Seat::STATE_SYNCED); }); 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, 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 fällt 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 einer entzogenen Zeile, die nie in der Nextcloud war', function () { // Anlegen → einladen → Auftrag scheitert an `no_instance` → entziehen. // 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 über die andere // Tür — und mit einem veralteten Grund aus einem anderen Vorgang. 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' => 'no_instance', 'nc_synced_at' => null, ]); Livewire::actingAs($user)->test(Users::class) ->call('retry', $sitz->uuid) // Und der Knopf steht dort gar nicht erst. ->assertDontSee("retry('{$sitz->uuid}')", escape: false); Queue::assertNothingPushed(); }); it('wiederholt nichts an einem Inhaber-Sitz', function () { // 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(); $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 ü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(); $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('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. Queue::fake(); mailversandEingerichtet(); $pve = new FakeProxmoxClient; 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([ 'email' => 'anna@firma.tld', 'role' => 'member', 'status' => 'active', 'nc_username' => 'anna@firma.tld', 'nc_state' => Seat::STATE_SYNCED, 'nc_synced_at' => now()->subYear(), ]); $seite = Livewire::actingAs($user)->test(Users::class); $seite->call('revoke', $sitz->uuid); $seite->set('inviteEmail', 'anna@firma.tld')->set('inviteRole', 'member') ->call('addSeat')->assertHasNoErrors(); expect($sitz->fresh()->nc_state)->toBe(Seat::STATE_NONE) ->and($sitz->fresh()->nc_error)->toBeNull() // 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(); // Und die Zeile sagt es auch: „angelegt — noch nicht eingeladen", mit dem // Knopf „Einladen" statt „Erneut senden". $seite->assertSee(__('users.state_none')) ->assertSee(__('users.invite')) ->assertDontSee(__('users.status_invited')); $seite->call('sendInvite', $sitz->uuid); (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('weist die Wiederaufnahme ab, wenn kein Platz mehr frei ist', function () { // 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(); 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 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(); 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'); }); // --- 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(); mailversandEingerichtet(); $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(, '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(); }); // --- Der Knopf, der eine Mail verspricht, die niemand verschicken kann. it('verschickt keine Einladung, solange der Mailversand nicht eingerichtet ist', function () { // Die Einladung verschickt NEXTCLOUD, nicht CluPilot — nur so entsteht das // Passwort dort, wo niemand sonst es zu sehen bekommt. Ohne eingerichteten // Versand kommt aber keine Mail heraus, und `occ user:add // --generate-password --email` GELINGT trotzdem: Nextcloud legt das Konto // an, versucht die Willkommensmail, protokolliert intern einen Fehler und // beendet mit 0. // // Die Zeile stünde also auf „Eingeladen", die Meldung verspräche einen // Link, an dem der Mitarbeiter sein Passwort selbst setzt — und niemand // bekäme etwas. Dieselbe Attrappe, gegen die dieses ganze Feature gebaut // wurde, eine Schicht tiefer. // // Kein ausgefülltes Postfach in dieser Fixture: das ist der Zustand, in // dem JEDE Instanz am ersten Tag steht, solange der Mailserver dieses // Produkts noch nicht existiert. 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('sendInvite', $sitz->uuid) // Kein Erfolg gemeldet. Auf den Satz selbst geprüft, nicht auf // irgendeine Meldung: „Einladung verschickt" ist genau das // Versprechen, das hier nicht zu halten ist. ->assertDispatched('notify', fn ($ereignis, $daten) => $daten['message'] !== __('users.invite_sent')); // Kein Auftrag, und der Sitz bleibt, wie er war — insbesondere ohne // Anmeldenamen, denn den vergibt erst das Einladen. Queue::assertNothingPushed(); expect($sitz->fresh()->nc_state)->toBe(Seat::STATE_NONE) ->and($sitz->fresh()->nc_username)->toBeNull(); }); it('sagt es auf der Seite, statt den Knopf verschwinden zu lassen', function () { // Der Hinweis steht dort, wo der Inhaber ihn VOR dem Klicken liest. Der // Knopf bleibt: die Einladen-Spalte an jeder Zeile verschwinden zu lassen // liest sich nicht als „geht hier gerade nicht", sondern als „das kann // dieses Produkt nicht" — genau die Beschwerde, die schon einmal zur // immer sichtbaren Aktionsspalte geführt hat. $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) ->assertSee(__('users.mail_not_ready')) ->assertSee("sendInvite('{$sitz->uuid}')", escape: false); }); it('laedt wieder ein, sobald der Versand steht', function () { // Die Gegenrichtung: die Wache darf nicht mehr abweisen, als sie soll. Queue::fake(); mailversandEingerichtet(); $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('sendInvite', $sitz->uuid) ->assertDontSee(__('users.mail_not_ready')); Queue::assertPushed(SyncSeatToNextcloud::class, fn ($auftrag) => $auftrag->action === 'invite'); }); it('verbraucht fuer eine abgewiesene Einladung kein Kontingent', function () { // Die Wache steht VOR dem Ratelimit. Sonst hätte ein Inhaber, dessen // Versand noch nicht steht, seine drei Versuche an dieser Zeile // verbraucht, bevor überhaupt einer hinausgehen konnte — und müsste // warten, sobald der Betrieb den Versand einrichtet. Queue::fake(); $customer = Customer::factory()->create(); $user = $customer->ensureUser(); Instance::factory()->for($customer)->create(['status' => 'active']); $sitz = Seat::factory()->for($customer)->create(['role' => 'member']); $seite = Livewire::actingAs($user)->test(Users::class); foreach (range(1, 3) as $ignoriert) { $seite->call('sendInvite', $sitz->uuid); } mailversandEingerichtet(); $seite->call('sendInvite', $sitz->uuid); Queue::assertPushed(SyncSeatToNextcloud::class, 1); }); it('legt weiterhin Sitze an, auch ohne Versand', function () { // Anlegen verspricht ausdrücklich KEINE Mail — ein Inhaber soll sein Team // vorbereiten können, ohne dass jemand eine bekommt. Die Wache gehört // deshalb an „Einladen" und nirgendwo sonst. Queue::fake(); $customer = Customer::factory()->create(); $user = $customer->ensureUser(); Instance::factory()->for($customer)->create(['status' => 'active']); Livewire::actingAs($user)->test(Users::class) ->set('inviteEmail', 'anna@firma.tld') ->call('addSeat') ->assertHasNoErrors(); expect($customer->seats()->where('email', 'anna@firma.tld')->exists())->toBeTrue(); }); 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) { if (str_contains($datei->getContents(), 'user:delete')) { $treffer[] = $datei->getRelativePathname(); } } expect($treffer)->toBe([]); });