diff --git a/VERSION b/VERSION index a295bf2..281fd4d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.83 +1.3.84 diff --git a/app/Livewire/Admin/Vpn.php b/app/Livewire/Admin/Vpn.php index dcf87c2..c97cc2a 100644 --- a/app/Livewire/Admin/Vpn.php +++ b/app/Livewire/Admin/Vpn.php @@ -385,10 +385,25 @@ class Vpn extends Component { $hub = app(WireguardHub::class); + // Einmal geladen, zweimal gelesen. Zwei Abfragen wären zwei Zeitpunkte: + // ein Zugang, der zwischen ihnen entsteht, stünde in keiner der beiden + // Gruppen — oder in beiden. + $peers = $this->visiblePeers()->with(['host', 'owner']) + ->orderByDesc('present')->orderBy('name')->get(); + + // Nach der geladenen Beziehung getrennt, nicht nach `kind`: die Plakette + // in der Zeile fragt dasselbe, und wer einen „Host-Zugang" liest, soll + // ihn auch unter den Hosts finden. Ein adoptierter Peer (kind=system) + // mit host_id gehört zu den Hosts, obwohl seine Art etwas anderes sagt. + $hostAccesses = $peers->filter(fn ($peer) => $peer->host !== null)->values(); + return view('livewire.admin.vpn', [ + 'peopleAccesses' => $peers->filter(fn ($peer) => $peer->host === null)->values(), + 'hostAccesses' => $hostAccesses, 'newConfig' => $config = ConfigHandoff::get($this->configToken), 'qrSvg' => $this->showQr && $config !== null ? QrCode::svg($config) : null, - 'peers' => $this->visiblePeers()->with(['host', 'owner'])->orderByDesc('present')->orderBy('name')->get(), + // Bleibt: der Leerzustand und alles, was die Liste als Ganzes zählt. + 'peers' => $peers, 'operators' => Operator::query() ->whereHas('roles', fn ($q) => $q->whereIn('name', Operator::operatorRoles())) ->orderBy('name')->get(['id', 'name', 'email']), diff --git a/lang/de/vpn.php b/lang/de/vpn.php index e00b40b..e993da6 100644 --- a/lang/de/vpn.php +++ b/lang/de/vpn.php @@ -18,6 +18,11 @@ return [ 'host_peer' => 'Host-Zugang', 'operator_peer' => 'Mitarbeiter-Zugang', + + // Die zwei Gruppen der Liste. Sie beantworten zwei verschiedene Fragen: + // wer von uns ist im Netz, und welche Maschinen hängen dran. + 'group_people' => 'Mitarbeiter', + 'group_hosts' => 'Hosts', 'unknown_peer' => 'Unbekannter Zugang', 'status_online' => 'Verbunden', diff --git a/lang/en/vpn.php b/lang/en/vpn.php index d3bcb13..f4adeec 100644 --- a/lang/en/vpn.php +++ b/lang/en/vpn.php @@ -18,6 +18,11 @@ return [ 'host_peer' => 'Host access', 'operator_peer' => 'Staff access', + + // The two groups in the list. They answer two different questions: who of + // us is on the network, and which machines are attached to it. + 'group_people' => 'Staff', + 'group_hosts' => 'Hosts', 'unknown_peer' => 'Unknown access', 'status_online' => 'Connected', diff --git a/resources/views/components/admin/vpn-peer-row.blade.php b/resources/views/components/admin/vpn-peer-row.blade.php new file mode 100644 index 0000000..fc71c6a --- /dev/null +++ b/resources/views/components/admin/vpn-peer-row.blade.php @@ -0,0 +1,122 @@ +@props(['peer']) + +{{-- + Eine Zeile der Zugangsliste. + + Ausgelagert, als die Liste in zwei Gruppen zerfiel (Personen und Hosts). + Sie zweimal hinzuschreiben hätte geheißen, sie ab dem nächsten Knopf an + zwei Stellen zu pflegen — und die zweite fällt erst auf, wenn jemand einen + Host-Zugang sucht und ihn anders aussehen sieht als seinen eigenen. + + Ursprünglicher Kopfkommentar der Liste, weil er die Form dieser Zeile + erklärt: Ein Register von Zugängen, keine Tabelle. Sieben Spalten ließen + jede davon zu schmal — Namen brachen um, „Letzter Kontakt" brach in seiner + eigenen Überschrift um, und die Zeile passte trotzdem nicht. Das sind + Eigenschaften EINES Zugangs, keine Größen, die jemand über eine Spalte + hinweg vergleicht: also je Zugang eine eigene Zeile, Identität oben, + Messwerte darunter in Mono, wo sie sich von selbst untereinander stellen. +--}} +@php + $status = $peer->status(); + $tone = [ + 'online' => 'border-success-border bg-success-bg text-success', + 'idle' => 'border-line-strong bg-surface-2 text-muted', + 'blocked' => 'border-danger-border bg-danger-bg text-danger', + 'pending' => 'border-warning-border bg-warning-bg text-warning', + ][$status]; + // Der Strich trägt den Zustand mit, damit ein gesperrter Zugang beim + // Überfliegen auffällt, ohne dass jemand eine Plakette liest. + $rule = [ + 'online' => 'bg-success-bright', + 'idle' => 'bg-line-strong', + 'blocked' => 'bg-danger', + 'pending' => 'bg-warning', + ][$status]; +@endphp +
  • + + +
    +
    + {{ $peer->name }} + + {{-- Die Plakette bleibt, obwohl die Gruppe daneben dasselbe sagt: + eine Zeile wandert beim Suchen aus ihrer Überschrift heraus, + und dann steht sie ohne sie da. --}} + + + {{ $peer->host ? __('vpn.host_peer') : __('vpn.operator_peer') }} + + + @if ($peer->owner) + {{ $peer->owner->name }} + @if ($peer->user_id === auth()->id()) + {{ __('vpn.you') }} + @endif + @elseif (! $peer->host) + {{ __('vpn.no_owner') }} + @endif +
    + + {{-- Die Messwerte, jeder mit eigener Beschriftung: ohne Kopfzeile + müssen sie sich selbst benennen. --}} +
    + {{ $peer->allowed_ip }} + @if ($peer->endpoint) + {{ $peer->endpoint }} + @endif + ↓ {{ \App\Support\Bytes::human($peer->rx_bytes) }}  ↑ {{ \App\Support\Bytes::human($peer->tx_bytes) }} + {{ __('vpn.handshake') }}: {{ $peer->last_handshake_at?->diffForHumans() ?? __('vpn.never') }} +
    +
    + +
    + + + {{ __('vpn.status_'.$status) }} + + +
    + {{-- Eine hinterlegte Konfiguration lässt sich erneut abholen; ohne + eine gibt es nichts auszugeben, und das zu sagen ist besser als + ein fehlender Knopf. --}} + @if (! $peer->hasStoredConfig() && $peer->kind === \App\Models\VpnPeer::KIND_STAFF && $peer->user_id === auth()->id()) + + + + @endif + @can('downloadConfig', $peer) + + @endcan + @can('block', $peer) + + @endcan + @can('update', $peer) + @if ($peer->kind === \App\Models\VpnPeer::KIND_STAFF) + + @endif + @endcan + @can('delete', $peer) + + @endcan +
    +
    +
  • diff --git a/resources/views/livewire/admin/vpn.blade.php b/resources/views/livewire/admin/vpn.blade.php index 9cc0a83..9db32f2 100644 --- a/resources/views/livewire/admin/vpn.blade.php +++ b/resources/views/livewire/admin/vpn.blade.php @@ -72,121 +72,35 @@ @if ($peers->isEmpty())

    {{ __('vpn.empty') }}

    @else - {{-- - A register of accesses, not a spreadsheet. + {{-- Zwei Gruppen, nicht eine Liste. - Seven columns in a fixed table left every one of them too - narrow: names broke across two lines, "Letzter Kontakt" - wrapped in its own heading, and the row still did not fit. - These are attributes of ONE access, not quantities anyone - compares down a column — so each access gets a row of its - own with the identity on the first line and the measurements - on the second, in mono where they line up by themselves. - --}} - + + @endforeach @endif diff --git a/tests/Feature/Admin/VpnTest.php b/tests/Feature/Admin/VpnTest.php index b0a6a00..c482b16 100644 --- a/tests/Feature/Admin/VpnTest.php +++ b/tests/Feature/Admin/VpnTest.php @@ -2,23 +2,31 @@ use App\Livewire\Admin\ConfirmDeleteVpnPeer; use App\Livewire\Admin\ConfirmReissueVpnPeer; +use App\Livewire\Admin\Settings; use App\Livewire\Admin\Vpn; use App\Livewire\Admin\VpnConfigAccess; use App\Models\Host; use App\Models\Operator; +use App\Models\User; use App\Models\VpnPeer; use App\Provisioning\Jobs\ApplyVpnPeer; +use App\Provisioning\Jobs\PurgeHost; +use App\Provisioning\Jobs\RemoveWireguardPeer; use App\Provisioning\Jobs\SyncVpnPeers; +use App\Services\Wireguard\ConfigHandoff; +use App\Services\Wireguard\ConfigVault; use App\Services\Wireguard\FakeWireguardHub; use App\Services\Wireguard\Keypair; use App\Services\Wireguard\LocalWireguardHub; use App\Services\Wireguard\PeerSnapshot; use App\Services\Wireguard\WireguardHub; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Queue; use Livewire\Livewire; +use Spatie\Permission\PermissionRegistrar; function vpnHub(): FakeWireguardHub { @@ -97,7 +105,7 @@ it('creates an access, hands out the config once and pushes it to the hub', func ->and($peer->enabled)->toBeTrue() ->and($peer->present)->toBeFalse(); // not on the hub until the job ran - $config = App\Services\Wireguard\ConfigHandoff::get($component->get('configToken')); + $config = ConfigHandoff::get($component->get('configToken')); expect($config)->toContain('[Interface]') ->toContain('Address = '.$peer->allowed_ip.'/32') ->toContain('PublicKey = HUBPUBLICKEY0000000000000000000000000000000=') @@ -197,7 +205,7 @@ it('stops mutations from a session whose capability was revoked mid-flight', fun // powers: every action re-checks, it does not trust the mount. And it must // refuse out loud, not quietly do nothing. $user->syncRoles(['Support']); - app(Spatie\Permission\PermissionRegistrar::class)->forgetCachedPermissions(); + app(PermissionRegistrar::class)->forgetCachedPermissions(); $component->call('toggle', $peer->uuid)->assertForbidden(); @@ -343,7 +351,7 @@ it('narrows what an open page shows once the account is demoted', function () { $component = Livewire::actingAs($user, 'operator')->test(Vpn::class)->assertSee('Fremdes-Notebook'); $user->syncRoles(['Support']); - app(Spatie\Permission\PermissionRegistrar::class)->forgetCachedPermissions(); + app(PermissionRegistrar::class)->forgetCachedPermissions(); // The five-second poll keeps working — the page is still theirs — but it // must stop serving other people's accesses to a demoted account. @@ -358,7 +366,7 @@ it('holds the same allocation lock the host pipeline uses', function () { // no unique index can catch the other side's insert — waiting is the only // thing that keeps a host and an access off the same tunnel IP. // Short TTL so the test measures the wait instead of sitting out a timeout. - expect(Illuminate\Support\Facades\Cache::lock('wireguard:allocate', 1)->get())->toBeTrue(); + expect(Cache::lock('wireguard:allocate', 1)->get())->toBeTrue(); $started = microtime(true); Livewire::actingAs(operator('Owner'), 'operator')->test(Vpn::class) @@ -393,7 +401,7 @@ it('serializes hub reads against hub mutations', function () { // A removal in flight holds this lock. A sync must wait for it instead of // acting on a snapshot taken around the mutation. - expect(Illuminate\Support\Facades\Cache::lock('wireguard:hub', 1)->get())->toBeTrue(); + expect(Cache::lock('wireguard:hub', 1)->get())->toBeTrue(); $started = microtime(true); (new SyncVpnPeers)->handle($hub); @@ -442,10 +450,10 @@ it('keeps host peer removal behind the same hub lock', function () { $hub = vpnHub(); $hub->addPeer('HOSTKEY', '10.66.0.30'); - expect(Illuminate\Support\Facades\Cache::lock('wireguard:hub', 1)->get())->toBeTrue(); + expect(Cache::lock('wireguard:hub', 1)->get())->toBeTrue(); $started = microtime(true); - (new App\Provisioning\Jobs\RemoveWireguardPeer('HOSTKEY'))->handle($hub); + (new RemoveWireguardPeer('HOSTKEY'))->handle($hub); expect(microtime(true) - $started)->toBeGreaterThan(0.8) ->and($hub->peerIps())->toBe([]); @@ -509,7 +517,7 @@ it('does not leave a phantom access behind when a host is purged', function () { (new SyncVpnPeers)->handle($hub); // console adopts the host peer expect(VpnPeer::query()->count())->toBe(1); - (new App\Provisioning\Jobs\PurgeHost($host->uuid))->handle(); + (new PurgeHost($host->uuid))->handle(); // No enabled-but-absent leftover the operator could toggle back on. expect(VpnPeer::query()->count())->toBe(0) @@ -537,7 +545,7 @@ it('stores the config only when asked, and encrypted', function () { // Not readable without the key: no plaintext key material in the column. ->and($peer->config_secret)->not->toContain('PrivateKey') ->and($peer->config_secret)->toStartWith('v1:') - ->and(App\Services\Wireguard\ConfigVault::decrypt($peer->config_secret))->toContain('[Interface]'); + ->and(ConfigVault::decrypt($peer->config_secret))->toContain('[Interface]'); }); it('refuses to store when there is no key to store it under', function () { @@ -558,7 +566,7 @@ it('hands a stored config back only to its owner, and only with the password', f $support = operator('Support'); $support->forceFill(['password' => Hash::make('geheim-1234')])->save(); $peer = VpnPeer::factory()->ownedBy($support)->create([ - 'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\nPrivateKey = XYZ\n"), + 'config_secret' => ConfigVault::encrypt("[Interface]\nPrivateKey = XYZ\n"), ]); // Wrong password: nothing comes back. @@ -570,7 +578,7 @@ it('hands a stored config back only to its owner, and only with the password', f // Right password: the config is handed over, and the retrieval is recorded. $modal->set('password', 'geheim-1234')->call('reveal')->assertHasNoErrors(); - expect(App\Services\Wireguard\ConfigHandoff::get($modal->get('token')))->toContain('PrivateKey = XYZ') + expect(ConfigHandoff::get($modal->get('token')))->toContain('PrivateKey = XYZ') ->and($peer->fresh()->download_count)->toBe(1) ->and($peer->fresh()->last_downloaded_at)->not->toBeNull(); }); @@ -579,7 +587,7 @@ it('never hands someone else the private key, not even an Owner', function () { vpnHub(); $support = operator('Support'); $peer = VpnPeer::factory()->ownedBy($support)->create([ - 'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\nPrivateKey = XYZ\n"), + 'config_secret' => ConfigVault::encrypt("[Interface]\nPrivateKey = XYZ\n"), ]); // The boss can see the access and revoke it — but holding its key is a @@ -611,7 +619,7 @@ it('locks out password guessing', function () { vpnHub(); $support = operator('Support'); $peer = VpnPeer::factory()->ownedBy($support)->create([ - 'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\n"), + 'config_secret' => ConfigVault::encrypt("[Interface]\n"), ]); $modal = Livewire::actingAs($support, 'operator')->test(VpnConfigAccess::class, ['uuid' => $peer->uuid]); @@ -637,7 +645,7 @@ it('sends the operator to sign in instead of a 500 when the session has ended', vpnHub(); $support = operator('Support'); $peer = VpnPeer::factory()->ownedBy($support)->create([ - 'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\nPrivateKey = XYZ\n"), + 'config_secret' => ConfigVault::encrypt("[Interface]\nPrivateKey = XYZ\n"), ]); $modal = Livewire::actingAs($support, 'operator')->test(VpnConfigAccess::class, ['uuid' => $peer->uuid]); @@ -654,7 +662,7 @@ it('destroys the stored config when the access is revoked', function () { Queue::fake(); $support = operator('Support'); $peer = VpnPeer::factory()->ownedBy($support)->create([ - 'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\nPrivateKey = XYZ\n"), + 'config_secret' => ConfigVault::encrypt("[Interface]\nPrivateKey = XYZ\n"), ]); Livewire::actingAs(operator('Owner'), 'operator') @@ -672,10 +680,10 @@ it('takes the tunnel away when a staff member is revoked', function () { Queue::fake(); $support = operator('Support'); $peer = VpnPeer::factory()->ownedBy($support)->create([ - 'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\n"), + 'config_secret' => ConfigVault::encrypt("[Interface]\n"), ]); - Livewire::actingAs(operator('Owner'), 'operator')->test(App\Livewire\Admin\Settings::class) + Livewire::actingAs(operator('Owner'), 'operator')->test(Settings::class) ->call('revokeStaff', $support->id); expect($support->fresh()->isOperator())->toBeFalse() @@ -692,7 +700,7 @@ it('removes a revoked colleague from the hub only after the rows are committed', // Sync queue: the job runs the moment it is dispatched, so if that happened // inside the transaction it would still see a live peer and leave it up. - Livewire::actingAs(operator('Owner'), 'operator')->test(App\Livewire\Admin\Settings::class) + Livewire::actingAs(operator('Owner'), 'operator')->test(Settings::class) ->call('revokeStaff', $support->id); expect($hub->peerIps())->toBe([]) @@ -705,14 +713,14 @@ it('stops serving a revealed config once the access is revoked', function () { $support = operator('Support'); $support->forceFill(['password' => Hash::make('geheim-1234')])->save(); $peer = VpnPeer::factory()->ownedBy($support)->create([ - 'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\nPrivateKey = XYZ\n"), + 'config_secret' => ConfigVault::encrypt("[Interface]\nPrivateKey = XYZ\n"), ]); $modal = Livewire::actingAs($support, 'operator')->test(VpnConfigAccess::class, ['uuid' => $peer->uuid]) ->set('password', 'geheim-1234') ->call('reveal'); $token = $modal->get('token'); - expect(App\Services\Wireguard\ConfigHandoff::get($token))->toContain('PrivateKey = XYZ'); + expect(ConfigHandoff::get($token))->toContain('PrivateKey = XYZ'); // Revoked while the modal is still open on someone's screen. Livewire::actingAs(operator('Owner'), 'operator') @@ -721,7 +729,7 @@ it('stops serving a revealed config once the access is revoked', function () { // The open modal must not keep handing the key out of the cache. $modal->call('toggleQr')->assertForbidden(); - expect(App\Services\Wireguard\ConfigHandoff::get($token))->toBeNull(); + expect(ConfigHandoff::get($token))->toBeNull(); }); it('never leaves the previous config on screen after another access is created', function () { @@ -733,7 +741,7 @@ it('never leaves the previous config on screen after another access is created', ->set('name', 'Erster')->set('ownerId', $owner->id) ->call('create'); $firstToken = $component->get('configToken'); - expect(App\Services\Wireguard\ConfigHandoff::get($firstToken))->toContain('PrivateKey'); + expect(ConfigHandoff::get($firstToken))->toContain('PrivateKey'); // Second access brings its own key, so there is no config to show — the // first one must not stay up wearing the second one's name. @@ -743,7 +751,7 @@ it('never leaves the previous config on screen after another access is created', expect($component->get('configToken'))->toBeNull() ->and($component->get('newConfigName'))->toBeNull() - ->and(App\Services\Wireguard\ConfigHandoff::get($firstToken))->toBeNull(); + ->and(ConfigHandoff::get($firstToken))->toBeNull(); }); it('does not keep polling while a private config is on screen', function () { @@ -766,7 +774,7 @@ it('does not keep polling while a private config is on screen', function () { it('will not issue an access to someone who is no longer staff', function () { vpnHub(); $owner = operator('Owner'); - $former = App\Models\User::factory()->create(); // no operator role + $former = User::factory()->create(); // no operator role // Not $former->id directly: operators and users are independent id // sequences, and the in-memory test database restarts both near 1 after @@ -789,14 +797,14 @@ it('closes the owner doors as soon as the roles are gone', function () { vpnHub(); $support = operator('Support'); $peer = VpnPeer::factory()->ownedBy($support)->create([ - 'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\n"), + 'config_secret' => ConfigVault::encrypt("[Interface]\n"), ]); expect($support->can('downloadConfig', $peer))->toBeTrue(); // Roles removed directly, not through revokeStaff() — ownership alone must // not keep the modals reachable. $support->syncRoles([]); - app(Spatie\Permission\PermissionRegistrar::class)->forgetCachedPermissions(); + app(PermissionRegistrar::class)->forgetCachedPermissions(); $support = $support->fresh(); expect($support->can('downloadConfig', $peer))->toBeFalse() @@ -820,8 +828,8 @@ it('re-issues a key for an access whose config was never stored', function () { $peer->refresh(); expect($peer->public_key)->not->toBe($oldKey) ->and($peer->present)->toBeFalse() - ->and(App\Services\Wireguard\ConfigHandoff::get($component->get('configToken'))) - ->toContain('Address = '.$peer->allowed_ip); + ->and(ConfigHandoff::get($component->get('configToken'))) + ->toContain('Address = '.$peer->allowed_ip); // Old key off the hub, new key on — in that order, or two peers would // briefly claim the same tunnel address. @@ -888,14 +896,14 @@ it('keeps a stored config in step when the key is re-issued', function () { Queue::fake(); $owner = operator('Owner'); $peer = VpnPeer::factory()->ownedBy($owner)->create([ - 'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\nPrivateKey = ALT\n"), + 'config_secret' => ConfigVault::encrypt("[Interface]\nPrivateKey = ALT\n"), ]); Livewire::actingAs($owner, 'operator')->test(Vpn::class)->call('reissue', $peer->uuid); // Otherwise the owner would download a configuration whose key the hub no // longer accepts. - $stored = App\Services\Wireguard\ConfigVault::decrypt($peer->fresh()->config_secret); + $stored = ConfigVault::decrypt($peer->fresh()->config_secret); expect($stored)->not->toContain('PrivateKey = ALT') ->and($stored)->toContain('Address = '.$peer->allowed_ip); }); @@ -962,7 +970,7 @@ it('re-issues even when the config vault is unavailable', function () { Queue::fake(); $owner = operator('Owner'); $peer = VpnPeer::factory()->ownedBy($owner)->create([ - 'config_secret' => App\Services\Wireguard\ConfigVault::encrypt("[Interface]\n"), + 'config_secret' => ConfigVault::encrypt("[Interface]\n"), ]); // The very situation the console tells people to fix by re-issuing. @@ -981,7 +989,7 @@ it('does not route the WireGuard endpoint through its own tunnel', function () { // at all. config()->set('provisioning.wireguard.subnet', '10.66.0.0/24'); - $method = new ReflectionMethod(App\Livewire\Admin\Vpn::class, 'allowedIps'); + $method = new ReflectionMethod(Vpn::class, 'allowedIps'); $method->setAccessible(true); foreach (['203.0.113.10:51820', '[2001:db8::10]:51820', 'vpn.example.test:51820'] as $endpoint) { @@ -994,11 +1002,11 @@ it('points a client at the tunnel resolver only when the console is reachable th // takes its name resolution down with it for as long as the tunnel is up. config()->set('provisioning.wireguard.hub_address', '10.66.0.1'); - $build = new ReflectionMethod(App\Livewire\Admin\Vpn::class, 'clientConfig'); + $build = new ReflectionMethod(Vpn::class, 'clientConfig'); $build->setAccessible(true); - $page = new App\Livewire\Admin\Vpn; + $page = new Vpn; - $keypair = new App\Services\Wireguard\Keypair('priv', 'pub'); + $keypair = new Keypair('priv', 'pub'); // Not ready: no resolver is running, so naming one would take the device's // whole name resolution down for as long as the tunnel is up. @@ -1008,3 +1016,39 @@ it('points a client at the tunnel resolver only when the console is reachable th config()->set('admin_access.vpn_ready', true); expect($build->invoke($page, $keypair, '10.66.0.9'))->toContain('DNS = 10.66.0.1'); }); + +/* +|-------------------------------------------------------------------------- +| Zwei Gruppen, nicht eine Liste +|-------------------------------------------------------------------------- +| +| Personen und Hosts standen gemischt untereinander, nach Verbindungszustand +| sortiert — ein Host-Zugang konnte also zwischen zwei Mitarbeitern stehen. +| Beide sind Peers im selben Netz, aber es sind zwei verschiedene Fragen: „wer +| von uns ist im Netz" und „welche Maschinen hängen dran". +*/ + +it('keeps host accesses in their own group, apart from the people', function () { + vpnHub(); + $host = Host::factory()->create(['name' => 'pve-fns-1']); + VpnPeer::factory()->create(['name' => 'pve-fns-1', 'host_id' => $host->id, 'kind' => VpnPeer::KIND_HOST]); + VpnPeer::factory()->create(['name' => 'Notebook Boban', 'host_id' => null, 'kind' => VpnPeer::KIND_STAFF]); + + Livewire::actingAs(admin(), 'operator')->test(Vpn::class) + ->assertViewHas('peopleAccesses', fn ($peers) => $peers->pluck('name')->all() === ['Notebook Boban']) + ->assertViewHas('hostAccesses', fn ($peers) => $peers->pluck('name')->all() === ['pve-fns-1']) + ->assertSee(__('vpn.group_people')) + ->assertSee(__('vpn.group_hosts')); +}); + +it('draws no heading for a group with nothing in it', function () { + // Eine leere Überschrift ist eine Aussage über nichts — und auf einer + // frischen Installation stünde „Hosts" über einer Lücke, bevor je einer + // angelegt wurde. + vpnHub(); + VpnPeer::factory()->create(['name' => 'Notebook Boban', 'host_id' => null, 'kind' => VpnPeer::KIND_STAFF]); + + Livewire::actingAs(admin(), 'operator')->test(Vpn::class) + ->assertSee(__('vpn.group_people')) + ->assertDontSee(__('vpn.group_hosts')); +});