diff --git a/app/Livewire/Servers/Groups.php b/app/Livewire/Servers/Groups.php new file mode 100644 index 0000000..6f846e0 --- /dev/null +++ b/app/Livewire/Servers/Groups.php @@ -0,0 +1,159 @@ +can('manage-fleet'), 403); + } + + public function title(): string + { + return __('groups.title'); + } + + private function gate(): void + { + abort_unless(Auth::user()?->can('manage-fleet'), 403); + } + + private function audit(string $action, string $target): void + { + AuditEvent::create([ + 'user_id' => Auth::id(), + 'actor' => Auth::user()?->name ?? 'system', + 'action' => $action, + 'target' => $target, + 'ip' => request()->ip(), + ]); + } + + public function create(): void + { + $this->gate(); + + $data = $this->validate([ + 'name' => ['required', 'string', 'max:60', Rule::unique('server_groups', 'name')], + 'color' => ['required', Rule::in(ServerGroup::COLORS)], + ]); + + $group = ServerGroup::create($data); + $this->audit('group.create', $group->name); + $this->reset('name'); + $this->color = 'accent'; + $this->dispatch('notify', message: __('groups.created', ['name' => $group->name])); + } + + public function rename(string $uuid, string $name): void + { + $this->gate(); + $group = ServerGroup::where('uuid', $uuid)->firstOrFail(); + + $name = trim($name); + $validated = validator( + ['name' => $name], + ['name' => ['required', 'string', 'max:60', Rule::unique('server_groups', 'name')->ignore($group->id)]], + )->validate(); + + $group->update(['name' => $validated['name']]); + $this->audit('group.update', $group->name); + } + + public function setColor(string $uuid, string $color): void + { + $this->gate(); + if (! in_array($color, ServerGroup::COLORS, true)) { + return; // saving() would coerce it anyway; refuse the bad token outright + } + $group = ServerGroup::where('uuid', $uuid)->firstOrFail(); + $group->update(['color' => $color]); + $this->audit('group.update', $group->name); + } + + /** Add or remove a server from a group (checkbox toggle in the assignment grid). */ + public function toggleMember(string $groupUuid, string $serverUuid): void + { + $this->gate(); + $group = ServerGroup::where('uuid', $groupUuid)->firstOrFail(); + $server = Server::where('uuid', $serverUuid)->firstOrFail(); + + $group->servers()->toggle($server->id); + $this->audit('group.members', $group->name); + } + + public function confirmDelete(string $uuid): void + { + $this->gate(); + $group = ServerGroup::where('uuid', $uuid)->firstOrFail(); + + $this->dispatch('openModal', + component: 'modals.confirm-action', + arguments: [ + 'heading' => __('groups.delete_heading'), + 'body' => __('groups.delete_body', ['name' => $group->name]), + 'confirmLabel' => __('common.delete'), + 'danger' => true, + 'icon' => 'trash', + 'notify' => __('groups.deleted', ['name' => $group->name]), + // The confirm modal writes the group.delete AuditEvent from this sealed descriptor, + // so deleteGroup() must NOT audit again (avoids a double row). + 'token' => ConfirmToken::issue('groupConfirmed', ['uuid' => $group->uuid], 'group.delete', $group->name, null), + ], + ); + } + + #[On('groupConfirmed')] + public function deleteGroup(string $confirmToken): void + { + $this->gate(); + + try { + $payload = ConfirmToken::consume($confirmToken, 'groupConfirmed'); + } catch (InvalidConfirmToken) { + return; // forged / replayed / direct-bypass attempt — no-op + } + + $group = ServerGroup::where('uuid', $payload['params']['uuid'])->first(); + if (! $group) { + return; + } + + // NOT audited here — the confirm modal already wrote group.delete from the sealed token. + $group->delete(); // pivot rows cascade; servers are untouched + } + + public function render(): View + { + return view('livewire.servers.groups', [ + 'groups' => ServerGroup::withCount('servers')->with('servers:id,uuid')->orderBy('name')->get(), + 'servers' => Server::orderBy('name')->get(['id', 'uuid', 'name']), + 'colors' => ServerGroup::COLORS, + ])->title($this->title()); + } +} diff --git a/app/Livewire/Servers/Index.php b/app/Livewire/Servers/Index.php index 4eaa98c..e3f7cb3 100644 --- a/app/Livewire/Servers/Index.php +++ b/app/Livewire/Servers/Index.php @@ -3,9 +3,11 @@ namespace App\Livewire\Servers; use App\Models\Server; +use App\Models\ServerGroup; use Illuminate\Contracts\View\View; use Livewire\Attributes\Layout; use Livewire\Attributes\On; +use Livewire\Attributes\Url; use Livewire\Component; #[Layout('layouts.app')] @@ -14,6 +16,10 @@ class Index extends Component /** Live search over name / IP. */ public string $search = ''; + /** Optional group filter (a ServerGroup uuid, shareable via the query string). */ + #[Url] + public ?string $group = null; + /** Page ; localized at runtime (attributes can't call __()). */ public function title(): string { @@ -31,7 +37,11 @@ class Index extends Component { $term = trim($this->search); + // Resolve the group filter; an unknown/removed uuid falls back to "all" (never errors). + $activeGroup = $this->group ? ServerGroup::where('uuid', $this->group)->first() : null; + $servers = Server::orderBy('name') + ->when($activeGroup, fn ($query) => $query->inGroup($activeGroup->id)) ->when($term !== '', function ($query) use ($term) { $query->where(function ($q) use ($term) { $q->where('name', 'like', "%{$term}%") @@ -48,6 +58,8 @@ class Index extends Component return view('livewire.servers.index', [ 'servers' => $servers, + 'groups' => ServerGroup::withCount('servers')->orderBy('name')->get(), + 'activeGroup' => $activeGroup, 'total' => (int) $counts->sum(), 'online' => (int) ($counts['online'] ?? 0), 'warning' => (int) ($counts['warning'] ?? 0), diff --git a/app/Models/Server.php b/app/Models/Server.php index 21965d1..fe4e1d5 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -4,6 +4,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Support\Str; @@ -32,6 +33,17 @@ class Server extends Model return $this->hasOne(SshCredential::class); } + public function groups(): BelongsToMany + { + return $this->belongsToMany(ServerGroup::class); + } + + /** Servers that belong to the given group (by group id). */ + public function scopeInGroup(Builder $query, int $groupId): void + { + $query->whereHas('groups', fn (Builder $q) => $q->where('server_groups.id', $groupId)); + } + /** * Servers whose stored SSH credential is present AND not disabled — the live poll set. * A disabled ("gesperrt") credential is refused by CredentialVault::resolve() at connect diff --git a/app/Models/ServerGroup.php b/app/Models/ServerGroup.php new file mode 100644 index 0000000..a466e3a --- /dev/null +++ b/app/Models/ServerGroup.php @@ -0,0 +1,38 @@ +<?php + +namespace App\Models; + +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Support\Str; + +class ServerGroup extends Model +{ + protected $guarded = []; + + /** Allowed @theme colour token keys for a group's dot/pill (R3 — never a raw hex). */ + public const COLORS = ['accent', 'cyan', 'online', 'warning', 'offline', 'ink']; + + protected static function booted(): void + { + static::creating(fn (self $g) => $g->uuid ??= (string) Str::uuid()); + // Fall back to the brand colour if an out-of-allow-list token slips in (defence in depth; + // the component validates too — this guarantees the token is always a real @theme colour). + static::saving(function (self $g): void { + if (! in_array($g->color, self::COLORS, true)) { + $g->color = 'accent'; + } + }); + } + + // R11: addressed by uuid in URLs, never the bigint id. + public function getRouteKeyName(): string + { + return 'uuid'; + } + + public function servers(): BelongsToMany + { + return $this->belongsToMany(Server::class); + } +} diff --git a/app/Support/Confirm/ConfirmToken.php b/app/Support/Confirm/ConfirmToken.php index 06852c5..c585a54 100644 --- a/app/Support/Confirm/ConfirmToken.php +++ b/app/Support/Confirm/ConfirmToken.php @@ -66,6 +66,7 @@ class ConfirmToken 'releaseStaged', 'releasePublic', 'releaseYank', + 'groupConfirmed', ]; /** How long an issued confirm stays valid (seconds) — generous for a human click. */ diff --git a/database/migrations/2026_07_05_100001_create_server_groups_table.php b/database/migrations/2026_07_05_100001_create_server_groups_table.php new file mode 100644 index 0000000..766c536 --- /dev/null +++ b/database/migrations/2026_07_05_100001_create_server_groups_table.php @@ -0,0 +1,31 @@ +<?php + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + public function up(): void + { + Schema::create('server_groups', function (Blueprint $table) { + $table->id(); + $table->uuid('uuid')->unique(); + $table->string('name')->unique(); + $table->string('color')->default('accent'); // a @theme token key (validated in the model) + $table->timestamps(); + }); + + Schema::create('server_server_group', function (Blueprint $table) { + $table->foreignId('server_id')->constrained()->cascadeOnDelete(); + $table->foreignId('server_group_id')->constrained()->cascadeOnDelete(); + $table->primary(['server_id', 'server_group_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('server_server_group'); + Schema::dropIfExists('server_groups'); + } +}; diff --git a/lang/de/audit.php b/lang/de/audit.php index 39366d0..4d86fd5 100644 --- a/lang/de/audit.php +++ b/lang/de/audit.php @@ -56,6 +56,10 @@ return [ 'file.edit' => 'Datei bearbeitet', 'firewall.rule_add' => 'Firewall-Regel hinzugefügt', 'firewall.rule_delete' => 'Firewall-Regel gelöscht', + 'group.create' => 'Server-Gruppe angelegt', + 'group.update' => 'Server-Gruppe geändert', + 'group.delete' => 'Server-Gruppe gelöscht', + 'group.members' => 'Gruppen-Zuordnung geändert', 'mail.configure' => 'E-Mail (SMTP) konfiguriert', 'password.change' => 'Passwort geändert', 'password.reset' => 'Passwort zurückgesetzt', diff --git a/lang/de/groups.php b/lang/de/groups.php new file mode 100644 index 0000000..6d4636b --- /dev/null +++ b/lang/de/groups.php @@ -0,0 +1,34 @@ +<?php + +return [ + 'title' => 'Server-Gruppen', + 'subtitle' => 'Flotte nach Umgebung oder Rolle organisieren', + 'eyebrow' => 'Flotte', + 'back_to_servers' => 'Zurück zu Servern', + + // New-group form + 'new_group' => 'Neue Gruppe', + 'name_label' => 'Name', + 'name_placeholder' => 'Produktion, Web, Kunde A …', + 'color_label' => 'Farbe', + 'create' => 'Anlegen', + + // List + 'members_count' => '{0}keine Server|{1}:count Server|[2,*]:count Server', + 'assign' => 'Server zuweisen', + 'no_servers' => 'Noch keine Server in der Flotte.', + 'empty_title' => 'Keine Gruppen', + 'empty' => 'Noch keine Gruppen angelegt. Lege oben die erste an.', + + // Filter (servers index) + 'filter_all' => 'Alle', + 'filter_label' => 'Nach Gruppe filtern', + + // Toasts + 'created' => 'Gruppe „:name“ angelegt.', + 'deleted' => 'Gruppe „:name“ gelöscht.', + + // Delete confirm + 'delete_heading' => 'Gruppe löschen', + 'delete_body' => 'Die Gruppe „:name“ wird gelöscht. Die Server bleiben bestehen, nur die Zuordnung entfällt.', +]; diff --git a/lang/en/audit.php b/lang/en/audit.php index f78aae1..65ae915 100644 --- a/lang/en/audit.php +++ b/lang/en/audit.php @@ -56,6 +56,10 @@ return [ 'file.edit' => 'File edited', 'firewall.rule_add' => 'Firewall rule added', 'firewall.rule_delete' => 'Firewall rule deleted', + 'group.create' => 'Server group created', + 'group.update' => 'Server group changed', + 'group.delete' => 'Server group deleted', + 'group.members' => 'Group membership changed', 'mail.configure' => 'E-mail (SMTP) configured', 'password.change' => 'Password changed', 'password.reset' => 'Password reset', diff --git a/lang/en/groups.php b/lang/en/groups.php new file mode 100644 index 0000000..5c3ef2e --- /dev/null +++ b/lang/en/groups.php @@ -0,0 +1,34 @@ +<?php + +return [ + 'title' => 'Server groups', + 'subtitle' => 'Organise the fleet by environment or role', + 'eyebrow' => 'Fleet', + 'back_to_servers' => 'Back to servers', + + // New-group form + 'new_group' => 'New group', + 'name_label' => 'Name', + 'name_placeholder' => 'Production, Web, Customer A …', + 'color_label' => 'Colour', + 'create' => 'Create', + + // List + 'members_count' => '{0}no servers|{1}:count server|[2,*]:count servers', + 'assign' => 'Assign servers', + 'no_servers' => 'No servers in the fleet yet.', + 'empty_title' => 'No groups', + 'empty' => 'No groups yet. Create the first one above.', + + // Filter (servers index) + 'filter_all' => 'All', + 'filter_label' => 'Filter by group', + + // Toasts + 'created' => 'Group “:name” created.', + 'deleted' => 'Group “:name” deleted.', + + // Delete confirm + 'delete_heading' => 'Delete group', + 'delete_body' => 'The group “:name” will be deleted. The servers remain; only the grouping is removed.', +]; diff --git a/resources/views/livewire/servers/groups.blade.php b/resources/views/livewire/servers/groups.blade.php new file mode 100644 index 0000000..4754f31 --- /dev/null +++ b/resources/views/livewire/servers/groups.blade.php @@ -0,0 +1,107 @@ +@php + // Colour token key -> literal @theme bg utility (literal strings so Tailwind's source scan + // generates them; a dynamic bg-{{ $var }} would never be emitted). R3: tokens only, no hex. + $dot = [ + 'accent' => 'bg-accent', 'cyan' => 'bg-cyan', 'online' => 'bg-online', + 'warning' => 'bg-warning', 'offline' => 'bg-offline', 'ink' => 'bg-ink-3', + ]; +@endphp + +<div class="space-y-5"> + {{-- Header --}} + <div class="flex flex-wrap items-end justify-between gap-3"> + <div> + <p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('groups.eyebrow') }}</p> + <h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">{{ __('groups.title') }}</h2> + <p class="mt-1 text-sm text-ink-3">{{ __('groups.subtitle') }}</p> + </div> + <a href="{{ route('servers.index') }}" wire:navigate + class="inline-flex min-h-11 items-center gap-1.5 rounded-md border border-line bg-inset px-3 text-xs text-ink-2 transition-colors hover:bg-raised hover:text-ink"> + <x-icon name="server" class="h-3.5 w-3.5" /> {{ __('groups.back_to_servers') }} + </a> + </div> + + {{-- New group --}} + <x-panel :title="__('groups.new_group')"> + <form wire:submit="create" class="grid gap-4 sm:grid-cols-[minmax(0,1fr)_auto_auto] sm:items-end"> + <div> + <label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('groups.name_label') }}</label> + <input wire:model="name" type="text" maxlength="60" placeholder="{{ __('groups.name_placeholder') }}" + class="h-9 w-full rounded-md border border-line bg-inset px-3 text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" /> + @error('name') <p class="mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror + </div> + <div> + <label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('groups.color_label') }}</label> + <div class="flex h-9 items-center gap-1.5"> + @foreach ($colors as $c) + <button type="button" wire:click="$set('color', '{{ $c }}')" + aria-label="{{ $c }}" aria-pressed="{{ $color === $c ? 'true' : 'false' }}" + @class(['grid h-6 w-6 place-items-center rounded-full transition', 'ring-2 ring-offset-2 ring-offset-surface ring-ink' => $color === $c])> + <span class="h-3.5 w-3.5 rounded-full {{ $dot[$c] }}"></span> + </button> + @endforeach + </div> + </div> + <x-btn variant="primary" size="lg" type="submit">{{ __('groups.create') }}</x-btn> + </form> + </x-panel> + + {{-- Groups list --}} + @if ($groups->isEmpty()) + <x-panel> + <div class="flex flex-col items-center gap-2 py-8 text-center"> + <x-icon name="folder" class="h-6 w-6 text-ink-4" /> + <p class="font-display text-sm font-semibold text-ink">{{ __('groups.empty_title') }}</p> + <p class="max-w-sm text-xs text-ink-3">{{ __('groups.empty') }}</p> + </div> + </x-panel> + @else + <div class="space-y-4"> + @foreach ($groups as $g) + <x-panel wire:key="group-{{ $g->uuid }}"> + <div class="flex flex-wrap items-center gap-3"> + <span class="h-3 w-3 shrink-0 rounded-full {{ $dot[$g->color] ?? 'bg-accent' }}"></span> + <input type="text" value="{{ $g->name }}" maxlength="60" + wire:change="rename('{{ $g->uuid }}', $event.target.value)" + class="min-w-0 flex-1 rounded-md border border-transparent bg-transparent px-2 py-1 font-display text-sm font-semibold text-ink hover:border-line focus:border-accent/40 focus:bg-inset focus:outline-none" /> + <span class="shrink-0 font-mono text-[11px] text-ink-3">{{ trans_choice('groups.members_count', $g->servers_count, ['count' => $g->servers_count]) }}</span> + + {{-- Recolour --}} + <div class="flex shrink-0 items-center gap-1"> + @foreach ($colors as $c) + <button type="button" wire:click="setColor('{{ $g->uuid }}', '{{ $c }}')" + aria-label="{{ $c }}" aria-pressed="{{ $g->color === $c ? 'true' : 'false' }}" + @class(['grid h-5 w-5 place-items-center rounded-full transition', 'ring-2 ring-offset-2 ring-offset-surface ring-ink' => $g->color === $c])> + <span class="h-3 w-3 rounded-full {{ $dot[$c] }}"></span> + </button> + @endforeach + </div> + + <x-modal-trigger variant="danger-soft" action="confirmDelete('{{ $g->uuid }}')">{{ __('common.delete') }}</x-modal-trigger> + </div> + + {{-- Membership grid --}} + <div class="mt-4 border-t border-line pt-4"> + <p class="mb-2 font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('groups.assign') }}</p> + @if ($servers->isEmpty()) + <p class="font-mono text-[11px] text-ink-3">{{ __('groups.no_servers') }}</p> + @else + <div class="grid gap-1.5 sm:grid-cols-2 lg:grid-cols-3"> + @php($memberIds = $g->servers->pluck('id')) + @foreach ($servers as $s) + <label class="flex min-h-11 items-center gap-2.5 rounded-md border border-line bg-inset px-3 py-2 text-sm text-ink-2 transition-colors hover:bg-raised sm:min-h-0"> + <input type="checkbox" wire:key="member-{{ $g->uuid }}-{{ $s->uuid }}" + @checked($memberIds->contains($s->id)) + wire:click="toggleMember('{{ $g->uuid }}', '{{ $s->uuid }}')" + class="h-4 w-4 shrink-0 rounded border-line bg-inset text-accent focus:ring-accent/40" /> + <span class="min-w-0 truncate">{{ $s->name }}</span> + </label> + @endforeach + </div> + @endif + </div> + </x-panel> + @endforeach + </div> + @endif +</div> diff --git a/resources/views/livewire/servers/index.blade.php b/resources/views/livewire/servers/index.blade.php index 8ff979d..abd60a9 100644 --- a/resources/views/livewire/servers/index.blade.php +++ b/resources/views/livewire/servers/index.blade.php @@ -1,5 +1,7 @@ @php $label = ['online' => __('servers.status_online'), 'warning' => __('servers.status_warning'), 'offline' => __('servers.status_offline'), 'pending' => __('servers.status_pending')]; + // Group colour token -> literal bg utility (see servers/groups.blade.php); R3 tokens only. + $dot = ['accent' => 'bg-accent', 'cyan' => 'bg-cyan', 'online' => 'bg-online', 'warning' => 'bg-warning', 'offline' => 'bg-offline', 'ink' => 'bg-ink-3']; @endphp <div class="space-y-4"> @@ -11,6 +13,10 @@ </div> <div class="flex items-center gap-2"> @can('manage-fleet') + <a href="{{ route('servers.groups') }}" wire:navigate + class="inline-flex min-h-11 items-center gap-1.5 rounded-md border border-line bg-inset px-3 text-xs text-ink-2 transition-colors hover:bg-raised hover:text-ink"> + <x-icon name="folder" class="h-3.5 w-3.5" /> {{ __('groups.title') }} + </a> <x-modal-trigger variant="accent" component="modals.create-server"> <x-icon name="plus" class="h-3.5 w-3.5" /> {{ __('servers.add_server') }} @@ -20,6 +26,30 @@ </div> </div> + {{-- Group filter (only when groups exist) --}} + @if ($groups->isNotEmpty()) + <div class="flex flex-wrap items-center gap-1.5" role="group" aria-label="{{ __('groups.filter_label') }}"> + <button type="button" wire:click="$set('group', null)" + @class([ + 'inline-flex min-h-9 items-center rounded-md border px-3 text-xs transition-colors', + 'border-accent/25 bg-accent/10 text-ink' => ! $activeGroup, + 'border-line bg-inset text-ink-2 hover:bg-raised hover:text-ink' => $activeGroup, + ])>{{ __('groups.filter_all') }}</button> + @foreach ($groups as $g) + <button type="button" wire:click="$set('group', '{{ $g->uuid }}')" + @class([ + 'inline-flex min-h-9 items-center gap-1.5 rounded-md border px-3 text-xs transition-colors', + 'border-accent/25 bg-accent/10 text-ink' => $activeGroup?->id === $g->id, + 'border-line bg-inset text-ink-2 hover:bg-raised hover:text-ink' => $activeGroup?->id !== $g->id, + ])> + <span class="h-2 w-2 rounded-full {{ $dot[$g->color] ?? 'bg-accent' }}"></span> + {{ $g->name }} + <span class="font-mono text-[10px] text-ink-4">{{ $g->servers_count }}</span> + </button> + @endforeach + </div> + @endif + {{-- KPI grid: 1 → 2 → 4 --}} <div class="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4"> <x-kpi :label="__('servers.kpi_total')" :value="$total" icon="server" /> diff --git a/routes/web.php b/routes/web.php index 4fc032f..7509159 100644 --- a/routes/web.php +++ b/routes/web.php @@ -208,6 +208,8 @@ Route::middleware('auth')->group(function () { Route::get('/', Dashboard::class)->name('dashboard'); Route::get('/servers', Servers\Index::class)->name('servers.index'); + // MUST be registered before /servers/{server}, or "groups" is captured as a server uuid. + Route::get('/servers/groups', Servers\Groups::class)->middleware('can:manage-fleet')->name('servers.groups'); Route::get('/servers/{server}', Servers\Show::class)->name('servers.show'); // History-graph data for the Server-Details chart (the Alpine island fetches this per range). diff --git a/tests/Feature/ServerGroupModelTest.php b/tests/Feature/ServerGroupModelTest.php new file mode 100644 index 0000000..5dcd896 --- /dev/null +++ b/tests/Feature/ServerGroupModelTest.php @@ -0,0 +1,73 @@ +<?php + +namespace Tests\Feature; + +use App\Models\Server; +use App\Models\ServerGroup; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Tests\TestCase; + +class ServerGroupModelTest extends TestCase +{ + use RefreshDatabase; + + public function test_a_group_gets_a_uuid_route_key(): void + { + $g = ServerGroup::create(['name' => 'Produktion', 'color' => 'accent']); + + $this->assertNotEmpty($g->uuid); + $this->assertSame('uuid', $g->getRouteKeyName()); + } + + public function test_color_falls_back_to_accent_for_an_unknown_token(): void + { + // R3: the colour must always be a real @theme token key — never arbitrary/raw text. + $g = ServerGroup::create(['name' => 'x', 'color' => 'rebeccapurple']); + + $this->assertSame('accent', $g->fresh()->color); + } + + public function test_a_known_color_token_is_kept(): void + { + $g = ServerGroup::create(['name' => 'y', 'color' => 'cyan']); + + $this->assertSame('cyan', $g->fresh()->color); + } + + public function test_servers_belong_to_groups_both_ways(): void + { + $prod = ServerGroup::create(['name' => 'prod', 'color' => 'online']); + $a = Server::create(['name' => 'a', 'ip' => '10.0.0.1', 'ssh_port' => 22, 'status' => 'online']); + $b = Server::create(['name' => 'b', 'ip' => '10.0.0.2', 'ssh_port' => 22, 'status' => 'online']); + + $prod->servers()->attach([$a->id, $b->id]); + + $this->assertEqualsCanonicalizing([$a->id, $b->id], $prod->servers->pluck('id')->all()); + $this->assertTrue($a->groups->contains($prod)); + } + + public function test_in_group_scope_filters_to_members(): void + { + $prod = ServerGroup::create(['name' => 'prod', 'color' => 'online']); + $member = Server::create(['name' => 'm', 'ip' => '10.0.0.1', 'ssh_port' => 22, 'status' => 'online']); + $outsider = Server::create(['name' => 'o', 'ip' => '10.0.0.2', 'ssh_port' => 22, 'status' => 'online']); + $prod->servers()->attach($member->id); + + $ids = Server::inGroup($prod->id)->pluck('id'); + + $this->assertTrue($ids->contains($member->id)); + $this->assertFalse($ids->contains($outsider->id)); + } + + public function test_deleting_a_group_detaches_its_servers_without_deleting_them(): void + { + $prod = ServerGroup::create(['name' => 'prod', 'color' => 'online']); + $server = Server::create(['name' => 'm', 'ip' => '10.0.0.1', 'ssh_port' => 22, 'status' => 'online']); + $prod->servers()->attach($server->id); + + $prod->delete(); + + $this->assertDatabaseHas('servers', ['id' => $server->id]); // server survives + $this->assertDatabaseMissing('server_server_group', ['server_id' => $server->id]); // pivot gone + } +} diff --git a/tests/Feature/ServerGroupsComponentTest.php b/tests/Feature/ServerGroupsComponentTest.php new file mode 100644 index 0000000..e935dd7 --- /dev/null +++ b/tests/Feature/ServerGroupsComponentTest.php @@ -0,0 +1,184 @@ +<?php + +namespace Tests\Feature; + +use App\Livewire\Servers\Groups; +use App\Livewire\Servers\Index as ServersIndex; +use App\Models\Server; +use App\Models\ServerGroup; +use App\Models\User; +use App\Support\Confirm\ConfirmToken; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Livewire\Livewire; +use Tests\TestCase; + +class ServerGroupsComponentTest extends TestCase +{ + use RefreshDatabase; + + 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]); + } + + // ── route gating (manage-fleet = admin only) ──────────────────────────────── + + public function test_viewer_cannot_open_the_groups_page(): void + { + $this->actingAs($this->viewer())->get('/servers/groups')->assertForbidden(); + } + + public function test_operator_cannot_open_the_groups_page(): void + { + $this->actingAs($this->operator())->get('/servers/groups')->assertForbidden(); + } + + public function test_admin_can_open_the_groups_page(): void + { + $this->actingAs($this->admin())->get('/servers/groups')->assertOk(); + } + + // ── create ────────────────────────────────────────────────────────────────── + + public function test_admin_creates_a_group_and_it_is_audited(): void + { + $this->actingAs($this->admin()); + + Livewire::test(Groups::class) + ->set('name', 'Produktion') + ->set('color', 'online') + ->call('create') + ->assertOk() + ->assertHasNoErrors(); + + $this->assertDatabaseHas('server_groups', ['name' => 'Produktion', 'color' => 'online']); + $this->assertDatabaseHas('audit_events', ['action' => 'group.create', 'target' => 'Produktion']); + } + + public function test_create_rejects_a_duplicate_name(): void + { + $this->actingAs($this->admin()); + ServerGroup::create(['name' => 'prod', 'color' => 'accent']); + + Livewire::test(Groups::class) + ->set('name', 'prod') + ->call('create') + ->assertHasErrors('name'); + + $this->assertSame(1, ServerGroup::where('name', 'prod')->count()); + } + + public function test_create_rejects_an_unknown_color_token(): void + { + $this->actingAs($this->admin()); + + Livewire::test(Groups::class) + ->set('name', 'x') + ->set('color', 'rebeccapurple') + ->call('create') + ->assertHasErrors('color'); + + $this->assertDatabaseCount('server_groups', 0); + } + + // ── rename / recolour / membership ────────────────────────────────────────── + + public function test_admin_renames_and_recolors_a_group(): void + { + $this->actingAs($this->admin()); + $g = ServerGroup::create(['name' => 'old', 'color' => 'accent']); + + Livewire::test(Groups::class) + ->call('rename', $g->uuid, 'Neu') + ->call('setColor', $g->uuid, 'cyan'); + + $this->assertDatabaseHas('server_groups', ['id' => $g->id, 'name' => 'Neu', 'color' => 'cyan']); + } + + public function test_set_color_ignores_an_unknown_token(): void + { + $this->actingAs($this->admin()); + $g = ServerGroup::create(['name' => 'g', 'color' => 'accent']); + + Livewire::test(Groups::class)->call('setColor', $g->uuid, 'rebeccapurple'); + + $this->assertSame('accent', $g->fresh()->color); // unchanged + } + + public function test_toggle_member_adds_then_removes_a_server(): void + { + $this->actingAs($this->admin()); + $g = ServerGroup::create(['name' => 'g', 'color' => 'accent']); + $s = Server::create(['name' => 's', 'ip' => '10.0.0.1', 'ssh_port' => 22, 'status' => 'online']); + + Livewire::test(Groups::class)->call('toggleMember', $g->uuid, $s->uuid); + $this->assertTrue($g->servers()->where('servers.id', $s->id)->exists()); + + Livewire::test(Groups::class)->call('toggleMember', $g->uuid, $s->uuid); + $this->assertFalse($g->servers()->where('servers.id', $s->id)->exists()); + } + + // ── delete via signed confirm token ───────────────────────────────────────── + + public function test_admin_deletes_a_group_via_a_confirmed_token(): void + { + $this->actingAs($this->admin()); + $g = ServerGroup::create(['name' => 'gone', 'color' => 'offline']); + + $token = ConfirmToken::issue('groupConfirmed', ['uuid' => $g->uuid], 'group.delete', $g->name, null); + ConfirmToken::confirm($token); // the modal confirmation step + + Livewire::test(Groups::class) + ->call('deleteGroup', $token) + ->assertOk(); + + $this->assertDatabaseMissing('server_groups', ['id' => $g->id]); + } + + public function test_a_forged_delete_token_is_a_no_op(): void + { + $this->actingAs($this->admin()); + $g = ServerGroup::create(['name' => 'stay', 'color' => 'accent']); + + Livewire::test(Groups::class)->call('deleteGroup', 'not-a-real-token'); + + $this->assertDatabaseHas('server_groups', ['id' => $g->id]); + } + + // ── fleet filter (Servers\Index) — open to any role ───────────────────────── + + public function test_group_filter_shows_only_members(): void + { + $this->actingAs($this->viewer()); // filtering is open to everyone + $g = ServerGroup::create(['name' => 'web', 'color' => 'cyan']); + $member = Server::create(['name' => 'web1', 'ip' => '10.0.0.1', 'ssh_port' => 22, 'status' => 'online']); + $other = Server::create(['name' => 'db1', 'ip' => '10.0.0.2', 'ssh_port' => 22, 'status' => 'online']); + $g->servers()->attach($member->id); + + Livewire::test(ServersIndex::class) + ->set('group', $g->uuid) + ->assertSee('web1') + ->assertDontSee('db1'); + } + + public function test_unknown_group_filter_falls_back_to_all(): void + { + $this->actingAs($this->viewer()); + Server::create(['name' => 'srv', 'ip' => '10.0.0.1', 'ssh_port' => 22, 'status' => 'online']); + + Livewire::test(ServersIndex::class) + ->set('group', 'no-such-uuid') + ->assertOk() + ->assertSee('srv'); // not filtered out by a bogus group + } +}