feat(fleet): server groups — organise the fleet + filter (feature 1/8)

Foundation for the feature program (groups are the scoping primitive alerts, ad-hoc
commands, bulk actions and patch/posture will target). Lean by design: named groups,
no hierarchy, no per-group RBAC.

- server_groups (uuid route key, unique name, color = a @theme token key coerced to a real
  token on save) + server_server_group M2M pivot (cascade on delete; servers survive a group
  delete). Server::groups() + a `scopeInGroup`.
- Servers\Groups full-page component, manage-fleet (admin) only — route can:-mw + mount()
  abort_unless + a per-method gate() on every mutation (create/rename/setColor/toggleMember/
  confirmDelete/deleteGroup), since /livewire/update does not re-run route mw. create/rename/
  recolour/membership audit directly; DELETE goes through the signed single-use ConfirmToken +
  the R5 confirm modal (which writes group.delete from the sealed descriptor, so the handler
  does not double-audit). 'groupConfirmed' added to ConfirmToken::ACTIONS.
- /servers/groups is registered BEFORE /servers/{server} so "groups" isn't captured as a uuid.
- Servers\Index: a #[Url] group filter (uuid; shareable) — an unknown uuid falls back to all,
  never errors. Filtering is open to every role; only management is admin-gated. A "Gruppen"
  button (admin) on the fleet header; colour pills use a literal @theme token map (R3).
- lang/{de,en}/groups.php + audit.php group.* labels (de/en parity). No emoji.

19 new tests (model + component: uuid/colour-allow-list/relations/cascade, RBAC route gating,
create/rename/recolour/membership, delete-via-confirmed-token, forged-token no-op, filter).
638 tests green, Pint, lang parity, Codex-reviewed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-07-05 19:36:17 +02:00
parent aa854916ce
commit 40d8dfc96d
15 changed files with 725 additions and 0 deletions

View File

@ -0,0 +1,159 @@
<?php
namespace App\Livewire\Servers;
use App\Models\AuditEvent;
use App\Models\Server;
use App\Models\ServerGroup;
use App\Support\Confirm\ConfirmToken;
use App\Support\Confirm\InvalidConfirmToken;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
/**
* Manage server groups (create / rename / recolour / delete + membership). Admin-only
* (manage-fleet, matching server create/delete): the route mw gates it AND every mutating
* method re-checks, since /livewire/update does not re-run route middleware. Every change is
* audited; deletion goes through the signed, single-use ConfirmToken + the R5 confirm modal.
*/
#[Layout('layouts.app')]
class Groups extends Component
{
/** New-group form. */
public string $name = '';
public string $color = 'accent';
public function mount(): void
{
abort_unless(Auth::user()?->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());
}
}

View File

@ -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 <title>; 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),

View File

@ -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

View File

@ -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);
}
}

View File

@ -66,6 +66,7 @@ class ConfirmToken
'releaseStaged',
'releasePublic',
'releaseYank',
'groupConfirmed',
];
/** How long an issued confirm stays valid (seconds) — generous for a human click. */

View File

@ -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');
}
};

View File

@ -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',

34
lang/de/groups.php Normal file
View File

@ -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.',
];

View File

@ -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',

34
lang/en/groups.php Normal file
View File

@ -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.',
];

View File

@ -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>

View File

@ -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" />

View File

@ -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).

View File

@ -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
}
}

View File

@ -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
}
}