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('laeuft 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 // koennen, 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(); $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('haelt den Benutzernamen fest, auch wenn die Mailadresse sich aendert', function () { // Nextcloud kann Benutzer nicht umbenennen. Ein Sitz, dessen Adresse sich // spaeter aendert, behaelt seinen Anmeldenamen. Queue::fake(); $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(); $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 Draufdruecken auf denselben Sitz ab', function () { // Die zweite Grenze: gegen das wiederholte Klicken an EINER Zeile, das // demselben Menschen sonst vier Willkommensmails schickt. 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, 4) as $ignoriert) { $seite->call('sendInvite', $sitz->uuid); } Queue::assertPushed(SyncSeatToNextcloud::class, 3); }); it('entzieht, ohne die Zeile zu loeschen', 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, gaebe es danach nichts mehr zu zeigen. expect(Seat::find($sitz->id))->not->toBeNull() ->and($sitz->fresh()->status)->toBe('revoked'); Queue::assertPushed(SyncSeatToNextcloud::class); }); it('schickt fuer einen Sitz, der nie in der Nextcloud war, keinen Auftrag', function () { // 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 traegt schon // einen Anmeldenamen, in der Nextcloud existiert aber nichts. Ein // `user:disable` darauf muesste scheitern und liesse die Zeile dauerhaft // rot stehen — eine Fehlermeldung fuer etwas, das nie ein Fehler war. 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 gefaehrlichste Halbschritt: `user:add` ist durch, `group:adduser` // nicht. Das Konto GIBT es ab jetzt — wuerde der Auftrag das verschweigen, // liesse ein spaeteres Entziehen die Sperre weg, weil die Seite den Sitz // fuer nie angekommen hielte. Der Zustand bleibt trotzdem 'failed'. $pve = new FakeProxmoxClient; $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($sitz->fresh()->nc_state)->toBe(Seat::STATE_FAILED) ->and($sitz->fresh()->nc_synced_at)->not->toBeNull(); }); it('spiegelt eine Rollenaenderung 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 ueber das Sperren wieder aktiv', function () { // Der Umschalter in suspend() kannte nur zwei Zustaende: an einer // entzogenen Zeile machte er beim ersten Klick 'suspended' und beim // zweiten 'active', samt `user:enable`. Der Mensch, dessen Zugang der // Inhaber beendet hat, koennte sich wieder anmelden — und er zaehlte // wieder gegen die Platzgrenze, die nur in addSeat() geprueft wird. 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('laedt einen entzogenen Sitz nicht erneut ein', function () { // Derselbe Weg zurueck, nur ueber die andere Tuer — und ebenfalls an der // Platzgrenze vorbei. Der richtige Weg ist ein NEUER Sitz. 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']); 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 // Draufdruecken. Die Zeile sagt stattdessen, dass sie endgueltig 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 laese sich wie ein Weg zurueck. $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_is_final')) ->assertDontSee(__('users.state_none')); }); it('laedt den Inhaber nicht zu seiner eigenen Cloud ein', function () { // Dieses Konto legt die Bereitstellung an (CreateCustomerAdmin). Ein // Auftrag von hier aus traefe entweder auf einen bestehenden Benutzer — // oder legte, solange die Bereitstellung noch nicht so weit ist, ein // ZWEITES Konto in der Gruppe `admin` an, neben dem echten. Queue::fake(); $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('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. $customer = Customer::factory()->create(); $user = $customer->ensureUser(); $sitz = Seat::factory()->for($customer)->owner()->create([ 'nc_state' => Seat::STATE_FAILED, 'nc_error' => 'no_instance', ]); 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('nennt in keiner Datei unter app/ das Loeschen eines Benutzers', function () { // Testerzwungene Regel: kein Nextcloud-Benutzer wird je geloescht, und // keine Datei. Wer das aendern will, muss diese Pruefung anfassen und // dabei ueber die Folgen stolpern. $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([]); });