feat(rbac): hide gated controls in the UI with @can (operator/viewer see no dead buttons)
parent
4f9699d470
commit
8b34ed8ef5
|
|
@ -33,7 +33,9 @@
|
|||
<x-nav-item icon="cpu" href="/services" :active="request()->is('services*')">{{ __('shell.nav_services') }}</x-nav-item>
|
||||
<x-nav-item icon="folder" href="/files" :active="request()->is('files*')">{{ __('shell.nav_files') }}</x-nav-item>
|
||||
<x-nav-item icon="audit" href="/audit" :active="request()->is('audit*')">{{ __('shell.nav_audit') }}</x-nav-item>
|
||||
@can('manage-network')
|
||||
<x-nav-item icon="lock" href="/wireguard" :active="request()->is('wireguard*')">{{ __('shell.nav_wireguard') }}</x-nav-item>
|
||||
@endcan
|
||||
<x-nav-item icon="terminal" href="/terminal" :active="request()->is('terminal*')" data-tour="terminal">{{ __('shell.nav_terminal') }}</x-nav-item>
|
||||
|
||||
<p class="px-3 pb-1 pt-3 font-mono text-[10px] uppercase tracking-widest text-ink-4">{{ __('shell.group_account') }}</p>
|
||||
|
|
|
|||
|
|
@ -49,12 +49,14 @@
|
|||
{{-- Listing --}}
|
||||
<x-panel :title="$path" :subtitle="__('files.panel_subtitle', ['dirs' => $dirs, 'files' => $files])" :padded="false">
|
||||
<x-slot:actions>
|
||||
@can('operate')
|
||||
<label class="inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md border border-accent/25 bg-accent/10 px-3 text-xs font-medium text-accent-text transition-colors hover:bg-accent/15">
|
||||
<x-icon name="plus" class="h-3.5 w-3.5" wire:loading.remove wire:target="upload" />
|
||||
<svg wire:loading wire:target="upload" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
|
||||
{{ __('files.upload') }}
|
||||
<input type="file" wire:model="upload" class="hidden" />
|
||||
</label>
|
||||
@endcan
|
||||
</x-slot:actions>
|
||||
|
||||
{{-- Column header (wide desktop only — below xl the rows render as stacked cards) --}}
|
||||
|
|
@ -132,7 +134,9 @@
|
|||
<x-btn variant="secondary" wire:click="download({{ $loop->index }})">{{ __('files.download') }}</x-btn>
|
||||
<x-modal-trigger variant="secondary" action="edit({{ $loop->index }})">{{ __('common.edit') }}</x-modal-trigger>
|
||||
@endunless
|
||||
@can('operate')
|
||||
<x-modal-trigger variant="danger-soft" action="confirmDelete({{ $loop->index }})">{{ __('common.delete') }}</x-modal-trigger>
|
||||
@endcan
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
|
|
|
|||
|
|
@ -31,10 +31,12 @@
|
|||
<div class="mt-5 flex items-center justify-end gap-2">
|
||||
<x-btn variant="secondary" wire:click="$dispatch('closeModal')">{{ __('common.close') }}</x-btn>
|
||||
@if ($loaded && ! $error && ! $tooBig && ! $binary && ! $this->isImage())
|
||||
@can('operate')
|
||||
<x-btn variant="primary" wire:click="save" wire:target="save" wire:loading.attr="disabled">
|
||||
<svg wire:loading wire:target="save" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
|
||||
{{ __('common.save') }}
|
||||
</x-btn>
|
||||
@endcan
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,10 +10,12 @@
|
|||
<h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">{{ __('servers.heading') }}</h2>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@can('manage-fleet')
|
||||
<x-modal-trigger variant="accent" component="modals.create-server">
|
||||
<x-icon name="plus" class="h-3.5 w-3.5" />
|
||||
{{ __('servers.add_server') }}
|
||||
</x-modal-trigger>
|
||||
@endcan
|
||||
<x-status-pill status="online">{{ __('servers.online_of_total', ['online' => $online, 'total' => $total]) }}</x-status-pill>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -117,6 +117,7 @@
|
|||
<span class="text-ink-2">{{ $cred->username }}</span>@<span>{{ $server->ip }}</span>
|
||||
</p>
|
||||
</div>
|
||||
@can('manage-fleet')
|
||||
<div class="flex shrink-0 flex-wrap items-center gap-2">
|
||||
<x-modal-trigger variant="secondary" component="modals.edit-credential" :arguments="['serverId' => $server->id]">
|
||||
<x-icon name="settings" class="h-3.5 w-3.5" /> {{ __('common.edit') }}
|
||||
|
|
@ -130,6 +131,7 @@
|
|||
<x-icon name="trash" class="h-3.5 w-3.5" /> {{ __('common.delete') }}
|
||||
</x-modal-trigger>
|
||||
</div>
|
||||
@endcan
|
||||
</div>
|
||||
@else
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-3 px-4 py-4 sm:px-5">
|
||||
|
|
@ -140,9 +142,11 @@
|
|||
<p class="truncate font-display text-sm font-semibold text-ink">{{ __('servers.cred_none_title') }}</p>
|
||||
<p class="mt-1.5 font-mono text-[11px] text-ink-3">{{ __('servers.cred_none_hint') }}</p>
|
||||
</div>
|
||||
@can('manage-fleet')
|
||||
<x-modal-trigger variant="accent" class="shrink-0" component="modals.edit-credential" :arguments="['serverId' => $server->id]">
|
||||
<x-icon name="plus" class="h-3.5 w-3.5" /> {{ __('servers.cred_deposit') }}
|
||||
</x-modal-trigger>
|
||||
@endcan
|
||||
</div>
|
||||
@endif
|
||||
</x-panel>
|
||||
|
|
@ -307,23 +311,29 @@
|
|||
</div>
|
||||
@if ($supported)
|
||||
@if ($check['key'] === 'fail2ban')
|
||||
@can('manage-network')
|
||||
<x-modal-trigger variant="secondary" size="sm" icon class="shrink-0" title="{{ __('servers.fail2ban_configure') }}"
|
||||
component="modals.fail2ban-config" :arguments="['serverId' => $server->id]">
|
||||
<x-icon name="settings" class="h-3.5 w-3.5" />
|
||||
</x-modal-trigger>
|
||||
@endcan
|
||||
@endif
|
||||
@if ($check['key'] === 'ssh_password' && $check['featureOn'])
|
||||
{{-- Safe auto-flow: generate+install a key, verify, switch the panel credential, THEN disable. --}}
|
||||
@can('manage-fleet')
|
||||
<x-modal-trigger variant="secondary" size="sm" class="shrink-0"
|
||||
component="modals.ssh-key-provision" :arguments="['serverId' => $server->id]">
|
||||
{{ __('servers.ssh_key_provision_action') }}
|
||||
</x-modal-trigger>
|
||||
@endcan
|
||||
@else
|
||||
@can('manage-network')
|
||||
<x-modal-trigger variant="secondary" size="sm" class="shrink-0"
|
||||
component="modals.hardening-action"
|
||||
:arguments="['serverId' => $server->id, 'action' => $check['key'], 'enable' => ! $check['featureOn']]">
|
||||
{{ $check['featureOn'] ? __('common.disable') : __('common.enable') }}
|
||||
</x-modal-trigger>
|
||||
@endcan
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
|
|
@ -386,12 +396,14 @@
|
|||
:subtitle="$fwToolLabel . ($fwActive ? __('servers.firewall_sub_active') : __('servers.firewall_sub_inactive'))"
|
||||
:padded="false">
|
||||
@if ($fwManageable)
|
||||
@can('manage-network')
|
||||
<x-slot:actions>
|
||||
<x-modal-trigger variant="accent" size="sm"
|
||||
component="modals.firewall-rule" :arguments="['serverId' => $server->id, 'tool' => $fwTool]">
|
||||
<x-icon name="plus" class="h-3.5 w-3.5" /> {{ __('servers.firewall_add_rule') }}
|
||||
</x-modal-trigger>
|
||||
</x-slot:actions>
|
||||
@endcan
|
||||
@endif
|
||||
|
||||
@if ($fwReadError)
|
||||
|
|
@ -402,10 +414,12 @@
|
|||
@elseif ($fwTool === 'firewalld' && ! $fwActive)
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 px-4 py-4 sm:px-5">
|
||||
<p class="font-mono text-[11px] text-ink-3">{{ __('servers.firewalld_inactive') }}</p>
|
||||
@can('manage-network')
|
||||
<x-modal-trigger variant="secondary" size="sm"
|
||||
component="modals.hardening-action" :arguments="['serverId' => $server->id, 'action' => 'firewall', 'enable' => true]">
|
||||
{{ __('common.enable') }}
|
||||
</x-modal-trigger>
|
||||
@endcan
|
||||
</div>
|
||||
@else
|
||||
{{-- Lone firewall: defaults/state in the left column, rules list in the
|
||||
|
|
@ -456,10 +470,12 @@
|
|||
'text-offline' => $ruleTone === 'offline',
|
||||
])>{{ strtolower($rule['action'] ?? 'allow') }}</span>
|
||||
@unless ($fwReadOnly)
|
||||
@can('manage-network')
|
||||
<x-modal-trigger variant="danger-soft" size="sm" icon class="shrink-0" title="{{ __('servers.firewall_remove_rule') }}"
|
||||
action="confirmDeleteRule({{ $loop->index }})">
|
||||
<x-icon name="trash" class="h-3.5 w-3.5" />
|
||||
</x-modal-trigger>
|
||||
@endcan
|
||||
@endunless
|
||||
</div>
|
||||
@empty
|
||||
|
|
@ -479,6 +495,7 @@
|
|||
:subtitle="$f2bActive ? __('servers.fail2ban_sub_active', ['count' => $f2bBanned]) : __('servers.fail2ban_sub_installed_inactive')"
|
||||
:padded="false">
|
||||
@if ($f2bActive)
|
||||
@can('manage-network')
|
||||
<x-slot:actions>
|
||||
<x-modal-trigger variant="secondary" size="sm" icon title="{{ __('servers.fail2ban_configure') }}"
|
||||
component="modals.fail2ban-config" :arguments="['serverId' => $server->id]">
|
||||
|
|
@ -490,6 +507,7 @@
|
|||
<x-icon name="lock" class="h-3.5 w-3.5" /> {{ __('servers.fail2ban_ban_ip') }}
|
||||
</x-modal-trigger>
|
||||
</x-slot:actions>
|
||||
@endcan
|
||||
@endif
|
||||
|
||||
@if ($f2bReadError)
|
||||
|
|
@ -500,10 +518,12 @@
|
|||
@elseif (! $f2bActive)
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 px-4 py-4 sm:px-5">
|
||||
<p class="font-mono text-[11px] text-ink-3">{{ __('servers.fail2ban_state_installed_inactive') }}</p>
|
||||
@can('manage-network')
|
||||
<x-modal-trigger variant="secondary" size="sm"
|
||||
component="modals.hardening-action" :arguments="['serverId' => $server->id, 'action' => 'fail2ban', 'enable' => true]">
|
||||
{{ __('common.enable') }}
|
||||
</x-modal-trigger>
|
||||
@endcan
|
||||
</div>
|
||||
@else
|
||||
{{-- Lone fail2ban: status summary + "Gesperrte IPs ansehen" in the left
|
||||
|
|
@ -522,10 +542,12 @@
|
|||
· <span @class(['text-offline' => $f2bBanned > 0])>{{ __('servers.fail2ban_banned', ['count' => $f2bBanned]) }}</span>
|
||||
· {{ __('servers.fail2ban_jails_count', ['count' => count($f2bJails)]) }}
|
||||
</p>
|
||||
@can('manage-network')
|
||||
<x-modal-trigger variant="secondary" size="sm" class="shrink-0"
|
||||
component="modals.fail2ban-bans" :arguments="['serverId' => $server->id]">
|
||||
<x-icon name="lock" class="h-3.5 w-3.5" /> {{ __('servers.fail2ban_view_bans') }}
|
||||
</x-modal-trigger>
|
||||
@endcan
|
||||
</div>
|
||||
|
||||
{{-- Whitelist (ignoreip) --}}
|
||||
|
|
@ -540,18 +562,22 @@
|
|||
<span wire:key="f2bi-{{ md5($ip) }}" class="inline-flex items-center gap-1.5 rounded-sm border border-line bg-inset px-2 py-1 font-mono text-[11px] text-ink-2">
|
||||
{{ $ip }}
|
||||
@unless (in_array($ip, ['127.0.0.1/8', '::1'], true))
|
||||
@can('manage-network')
|
||||
<button type="button" wire:click="removeIgnore({{ \Illuminate\Support\Js::from($ip) }})" class="text-ink-4 hover:text-offline" title="{{ __('common.remove') }}">
|
||||
<x-icon name="x" class="h-3 w-3" />
|
||||
</button>
|
||||
@endcan
|
||||
@endunless
|
||||
</span>
|
||||
@endforeach
|
||||
</div>
|
||||
@can('manage-network')
|
||||
<div class="mt-2.5 flex items-center gap-2">
|
||||
<input wire:model="newIgnoreIp" wire:keydown.enter="addIgnore" type="text" placeholder="{{ __('servers.whitelist_placeholder') }}"
|
||||
class="h-8 w-full max-w-xs rounded-md border border-line bg-inset px-2.5 font-mono text-[11px] text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
|
||||
<x-btn variant="secondary" size="sm" wire:click="addIgnore">{{ __('common.add') }}</x-btn>
|
||||
</div>
|
||||
@endcan
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
|
@ -616,11 +642,13 @@
|
|||
|
||||
{{-- SSH keys --}}
|
||||
<x-panel :title="__('servers.ssh_keys_title')" :subtitle="__('servers.ssh_keys_authorized', ['count' => count($sshKeys)])" :padded="false">
|
||||
@can('manage-fleet')
|
||||
<x-slot:actions>
|
||||
<x-modal-trigger variant="accent" component="modals.add-ssh-key" :arguments="['serverId' => $server->id]">
|
||||
<x-icon name="plus" class="h-3.5 w-3.5" /> {{ __('servers.ssh_keys_add') }}
|
||||
</x-modal-trigger>
|
||||
</x-slot:actions>
|
||||
@endcan
|
||||
<div class="divide-y divide-line">
|
||||
@forelse ($sshKeys as $key)
|
||||
<div wire:key="sshk-{{ md5($key['fingerprint'] ?? $loop->index) }}" class="flex items-center gap-3 px-4 py-3 sm:px-5">
|
||||
|
|
@ -634,9 +662,11 @@
|
|||
</div>
|
||||
<p class="truncate font-mono text-[11px] text-ink-3">{{ $key['fingerprint'] }}</p>
|
||||
</div>
|
||||
@can('manage-fleet')
|
||||
<x-modal-trigger variant="danger-soft" icon action="confirmKeyRemoval({{ $loop->index }})" title="{{ __('servers.ssh_keys_remove') }}">
|
||||
<x-icon name="trash" class="h-3.5 w-3.5" />
|
||||
</x-modal-trigger>
|
||||
@endcan
|
||||
</div>
|
||||
@empty
|
||||
<p class="px-5 py-8 text-center font-mono text-[11px] text-ink-3">{{ __('servers.ssh_keys_none') }}</p>
|
||||
|
|
|
|||
|
|
@ -72,11 +72,13 @@
|
|||
<x-status-pill :status="$svc['status']">{{ $svcLabel[$svc['status']] }}</x-status-pill>
|
||||
|
||||
{{-- R5: wire to wire-elements/modal --}}
|
||||
@can('operate')
|
||||
<div class="flex items-center gap-1.5">
|
||||
<x-modal-trigger variant="secondary" action="confirm('start', {{ $loop->index }})" wire:loading.attr="disabled" :disabled="$svc['status'] === 'online'">{{ __('services.start') }}</x-modal-trigger>
|
||||
<x-modal-trigger variant="secondary" action="confirm('stop', {{ $loop->index }})" wire:loading.attr="disabled" :disabled="$svc['status'] === 'offline'">{{ __('services.stop') }}</x-modal-trigger>
|
||||
<x-modal-trigger variant="accent" action="confirm('restart', {{ $loop->index }})" wire:loading.attr="disabled" :disabled="$svc['status'] === 'offline'">{{ __('services.restart') }}</x-modal-trigger>
|
||||
</div>
|
||||
@endcan
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
|
|
|
|||
|
|
@ -39,9 +39,11 @@
|
|||
<x-icon name="rotate" class="h-3.5 w-3.5 shrink-0" />{{ __('system.restart_running') }}
|
||||
</p>
|
||||
@else
|
||||
@can('manage-panel')
|
||||
<x-btn variant="primary" wire:click="restartNow" wire:loading.attr="disabled" wire:target="restartNow" class="shrink-0">
|
||||
<x-icon name="rotate" class="h-3.5 w-3.5" /> {{ __('system.restart_now') }}
|
||||
</x-btn>
|
||||
@endcan
|
||||
@endif
|
||||
</div>
|
||||
<p class="mt-2 font-mono text-[11px] leading-relaxed text-ink-4">{{ __('system.restart_watcher_hint') }}</p>
|
||||
|
|
@ -79,9 +81,11 @@
|
|||
autocapitalize="off" spellcheck="false"
|
||||
placeholder="{{ __('system.domain_placeholder') }}"
|
||||
class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
|
||||
@can('manage-panel')
|
||||
<x-modal-trigger variant="primary" action="confirmDomain" class="shrink-0">
|
||||
<x-icon name="save" class="h-3.5 w-3.5" /> {{ __('common.save') }}
|
||||
</x-modal-trigger>
|
||||
@endcan
|
||||
</div>
|
||||
@error('domainInput')
|
||||
<p class="mt-1.5 font-mono text-[11px] text-offline">{{ $message }}</p>
|
||||
|
|
@ -94,6 +98,7 @@
|
|||
<p class="text-sm font-medium text-ink-2">{{ __('system.tls_mode_title') }}</p>
|
||||
|
||||
{{-- Segmented control --}}
|
||||
@can('manage-panel')
|
||||
<div role="radiogroup" aria-label="{{ __('system.tls_mode_title') }}" class="flex w-full rounded-md border border-line bg-inset p-1">
|
||||
<button type="button"
|
||||
role="radio"
|
||||
|
|
@ -128,6 +133,7 @@
|
|||
<span x-show="!pending" class="contents">@if ($tlsMode === 'external')<x-icon name="shield" class="h-3.5 w-3.5" />@endif{{ __('system.tls_mode_external') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
@endcan
|
||||
|
||||
{{-- Per-mode description --}}
|
||||
<div class="flex items-start gap-3 rounded-md border border-line bg-raised/40 p-3">
|
||||
|
|
@ -211,12 +217,14 @@
|
|||
<span class="text-ink-3">{{ __('system.cert_status_unknown') }}</span>
|
||||
@endswitch
|
||||
</p>
|
||||
@can('manage-panel')
|
||||
<x-btn variant="secondary" size="sm" class="mt-2.5"
|
||||
wire:click="requestCertificate" wire:target="requestCertificate" wire:loading.attr="disabled">
|
||||
<x-icon name="shield" class="h-3.5 w-3.5" wire:loading.remove wire:target="requestCertificate" />
|
||||
<svg wire:loading wire:target="requestCertificate" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
|
||||
{{ __('system.cert_button') }}
|
||||
</x-btn>
|
||||
@endcan
|
||||
</div>
|
||||
@elseif ($tlsMode === 'external')
|
||||
<div class="rounded-md border border-line bg-inset/60 p-3">
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
<x-panel :title="__('terminal.targets_title')" :subtitle="__('terminal.targets_subtitle')" :padded="false">
|
||||
<div class="space-y-1 p-2">
|
||||
{{-- Clusev host — a real SSH login into the host machine (configured by the operator). --}}
|
||||
@can('manage-fleet')
|
||||
<div @class([
|
||||
'flex items-center rounded-md border transition-colors',
|
||||
'border-accent/30 bg-accent/10' => $activeKey === 'host',
|
||||
|
|
@ -31,6 +32,7 @@
|
|||
<x-icon name="settings" class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
@endcan
|
||||
|
||||
<div class="my-1.5 px-3 font-mono text-[10px] uppercase tracking-[0.18em] text-ink-4">{{ __('terminal.servers_heading') }}</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -79,11 +79,13 @@
|
|||
<x-icon name="rotate" class="h-3.5 w-3.5 shrink-0 animate-spin" />{{ __('versions.update_running') }}
|
||||
</p>
|
||||
@elseif ($isUpdate)
|
||||
@can('manage-panel')
|
||||
<x-btn variant="primary" wire:click="requestUpdate" wire:target="requestUpdate" wire:loading.attr="disabled" class="w-full sm:w-auto sm:shrink-0">
|
||||
<svg wire:loading wire:target="requestUpdate" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
|
||||
<x-icon name="rotate" class="h-3.5 w-3.5" wire:loading.remove wire:target="requestUpdate" />
|
||||
{{ __('versions.update_now') }}
|
||||
</x-btn>
|
||||
@endcan
|
||||
@endif
|
||||
@if ($lastChecked)
|
||||
<span class="font-mono text-[11px] text-ink-4 sm:shrink-0">{{ __('versions.checked_at', ['time' => $lastChecked]) }}</span>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,138 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Livewire\Servers\Index as Servers;
|
||||
use App\Livewire\Services\Index as Services;
|
||||
use App\Models\Server;
|
||||
use App\Models\User;
|
||||
use App\Services\FleetService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* RBAC view-level HIDING: operators/viewers must not SEE the controls they would be
|
||||
* 403'd from (the server-side abort_unless guards are proven in the Rbac*GateTest
|
||||
* suites). This only asserts the `@can(...)` wrappers render/omit the button markup —
|
||||
* a light UX check across a few representative surfaces, one per ability family:
|
||||
* - Servers\Index add-server trigger → manage-fleet (admin sees, viewer does not);
|
||||
* - Services\Index start/stop/restart → operate (operator sees, viewer does not);
|
||||
* - the sidebar WireGuard nav item → manage-network(admin sees, viewer does not).
|
||||
* Every SSH read is mocked — no real host is ever touched.
|
||||
*/
|
||||
class RbacUiHidingTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Mockery::close();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function admin(): User
|
||||
{
|
||||
return User::factory()->create(['must_change_password' => false]);
|
||||
}
|
||||
|
||||
private function operator(): User
|
||||
{
|
||||
return User::factory()->operator()->create(['must_change_password' => false]);
|
||||
}
|
||||
|
||||
private function viewer(): User
|
||||
{
|
||||
return User::factory()->viewer()->create(['must_change_password' => false]);
|
||||
}
|
||||
|
||||
/** An online server with a credential, selected as the active fleet server. */
|
||||
private function activeServer(): Server
|
||||
{
|
||||
$server = Server::create(['name' => 'box', 'ip' => '10.0.0.2', 'ssh_port' => 22, 'status' => 'online']);
|
||||
$server->credential()->create(['username' => 'root', 'auth_type' => 'password', 'secret' => 'x']);
|
||||
session(['active_server_id' => $server->id]);
|
||||
|
||||
return $server;
|
||||
}
|
||||
|
||||
// ── manage-fleet: Servers\Index add-server trigger ──────────────────────────
|
||||
|
||||
public function test_admin_sees_the_add_server_trigger(): void
|
||||
{
|
||||
$this->actingAs($this->admin());
|
||||
|
||||
Livewire::test(Servers::class)
|
||||
->assertOk()
|
||||
->assertSee(__('servers.add_server')); // "Server hinzufügen"
|
||||
}
|
||||
|
||||
public function test_viewer_does_not_see_the_add_server_trigger(): void
|
||||
{
|
||||
$this->actingAs($this->viewer());
|
||||
|
||||
Livewire::test(Servers::class)
|
||||
->assertOk() // the fleet list still renders
|
||||
->assertDontSee(__('servers.add_server')); // but the create control is gone
|
||||
}
|
||||
|
||||
// ── operate: Services\Index start/stop/restart action buttons ───────────────
|
||||
|
||||
/** Stubs the systemd read so the list renders with one service row. */
|
||||
private function stubServices(): void
|
||||
{
|
||||
$fleet = Mockery::mock(FleetService::class);
|
||||
$fleet->shouldReceive('systemd')->andReturn([
|
||||
'services' => [['name' => 'nginx.service', 'status' => 'offline', 'sub' => 'dead', 'enabled' => true, 'desc' => 'web']],
|
||||
'journal' => [],
|
||||
'cursor' => null,
|
||||
]);
|
||||
app()->instance(FleetService::class, $fleet);
|
||||
}
|
||||
|
||||
public function test_operator_sees_a_service_action_control(): void
|
||||
{
|
||||
$this->actingAs($this->operator());
|
||||
$this->activeServer();
|
||||
$this->stubServices();
|
||||
|
||||
Livewire::test(Services::class)
|
||||
->call('load')
|
||||
->assertOk()
|
||||
->assertSee(__('services.restart')); // "Neustart" — a per-row action button
|
||||
}
|
||||
|
||||
public function test_viewer_does_not_see_a_service_action_control(): void
|
||||
{
|
||||
$this->actingAs($this->viewer());
|
||||
$this->activeServer();
|
||||
$this->stubServices();
|
||||
|
||||
Livewire::test(Services::class)
|
||||
->call('load')
|
||||
->assertOk()
|
||||
->assertSee('nginx.service') // the list itself is visible
|
||||
->assertDontSee(__('services.restart')); // but the action buttons are gone
|
||||
}
|
||||
|
||||
// ── manage-network: sidebar WireGuard nav item ──────────────────────────────
|
||||
|
||||
public function test_admin_sees_the_wireguard_nav(): void
|
||||
{
|
||||
$this->actingAs($this->admin());
|
||||
|
||||
$this->get('/servers')
|
||||
->assertOk()
|
||||
->assertSee(__('shell.nav_wireguard')); // "WireGuard" nav item present
|
||||
}
|
||||
|
||||
public function test_viewer_does_not_see_the_wireguard_nav(): void
|
||||
{
|
||||
$this->actingAs($this->viewer());
|
||||
|
||||
$this->get('/servers')
|
||||
->assertOk()
|
||||
->assertDontSee(__('shell.nav_wireguard')); // hidden for a viewer
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue