diff --git a/CHANGELOG.md b/CHANGELOG.md index bef1dee..4b40799 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,19 @@ getaggte Releases (Kanal `stable`, optional `beta`) — niemals Entwicklungs-Bui _Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `vX.Y.Z` getaggt._ +## [0.9.9] - 2026-06-19 + +### Hinzugefügt +- **„Jetzt aktualisieren" im Dashboard (System → Version & Releases).** Ist ein neueres Release im + Kanal verfügbar, lässt sich das Update jetzt per Knopf auslösen — ganz ohne SSH. Gleiches sichere + Muster wie der Neustart-Knopf: der Container schreibt nur eine Sentinel-Datei (kein Docker-Socket), + ein **als root laufender** Host-Watcher (`clusev-update.path`/`.service`) führt `update.sh` aus + (`git pull` + idempotenter Re-Install) und verbraucht die Sentinel **vor** dem Lauf, damit ein + dauerhafter Fehler nicht endlos wiederholt. Der Knopf erscheint nur, wenn eine neuere Version + gefunden wurde, ist per-Nutzer gedrosselt (3/10 Min, auto-ablaufend) und auditiert + (`deploy.update_request`). Hinweis im UI: das Dashboard ist während des Rebuilds ein bis zwei + Minuten nicht erreichbar; danach neu laden. `install.sh` installiert die neuen Host-Units mit. + ## [0.9.8] - 2026-06-19 ### Hinzugefügt diff --git a/app/Livewire/Versions/Index.php b/app/Livewire/Versions/Index.php index bbad0aa..5e30d7d 100644 --- a/app/Livewire/Versions/Index.php +++ b/app/Livewire/Versions/Index.php @@ -2,9 +2,13 @@ namespace App\Livewire\Versions; +use App\Models\AuditEvent; use App\Models\Setting; +use App\Services\DeploymentService; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\RateLimiter; use Livewire\Attributes\Layout; use Livewire\Component; @@ -30,6 +34,9 @@ class Index extends Component /** 'current' | 'update' | null — drives the banner tone. */ public ?string $updateState = null; + /** Set once an update has been requested this session — the stack is rebuilding. */ + public bool $updateRequested = false; + /** * Honest update check: compare the installed version against the newest release * tag visible in the channel. No external server, no stars, no CVE feed. @@ -55,6 +62,54 @@ class Index extends Component } } + /** + * Operator-triggered update: write the update sentinel a host watcher reacts to by running + * update.sh (git pull + idempotent re-install). Re-checks that a newer release than the + * installed one is actually published in the channel (never fires blindly), is per-user + * throttled (auto-expiring — never a control-plane lockout) and audited. The container never + * runs git/Docker itself. The dashboard briefly goes down while the stack rebuilds, so we + * say so and do not await a result. + */ + public function requestUpdate(DeploymentService $deployment): void + { + $channel = $this->channel(); + $installed = (string) config('clusev.version'); + $latest = $this->resolveLatestTag($channel, forceRemote: true); + + // Reflect the freshly-checked state, then refuse if there is nothing newer to apply. + $this->lastChecked = now()->format('H:i'); + if ($latest === null || ! $this->isNewer($latest, $installed)) { + $this->updateState = 'current'; + $this->updateStatus = __('versions.status_current', ['installed' => $installed, 'channel' => $channel]); + $this->dispatch('notify', message: __('versions.update_not_available'), level: 'error'); + + return; + } + $this->updateState = 'update'; + $this->updateStatus = __('versions.status_update_available', ['latest' => $latest, 'channel' => $channel]); + + $key = 'update-request:'.Auth::id(); + if (RateLimiter::tooManyAttempts($key, 3)) { + $this->dispatch('notify', message: __('versions.update_throttled', ['seconds' => RateLimiter::availableIn($key)]), level: 'error'); + + return; + } + RateLimiter::hit($key, 600); + + $deployment->requestUpdate(); + + AuditEvent::create([ + 'user_id' => Auth::id(), + 'actor' => Auth::user()?->name ?? 'system', + 'action' => 'deploy.update_request', + 'target' => $installed.' → '.$latest.' ('.$channel.')', + 'ip' => request()->ip(), + ]); + + $this->updateRequested = true; + $this->dispatch('notify', message: __('versions.update_started')); + } + /** Resolve the configured channel, clamped to the user-facing set. */ private function channel(): string { diff --git a/app/Services/DeploymentService.php b/app/Services/DeploymentService.php index e8050cc..2227a6d 100644 --- a/app/Services/DeploymentService.php +++ b/app/Services/DeploymentService.php @@ -45,6 +45,13 @@ class DeploymentService */ private const RESTART_FILE = 'app/restart-signal/restart.request'; + /** + * Update sentinel — same mechanism and bind-mounted directory as RESTART_FILE, but the + * host watcher reacts by running update.sh (git pull + idempotent re-install) instead of a + * plain restart. Separate filename so the two requests trigger distinct host units. + */ + private const UPDATE_FILE = 'app/restart-signal/update.request'; + /** * The PENDING domain: dashboard override (Setting `panel_domain`) if a row exists * (empty row = explicit bare IP), otherwise the install-time APP_DOMAIN. This is @@ -194,6 +201,46 @@ class DeploymentService } } + /** Absolute path of the update sentinel on the shared (bind-mounted) volume. */ + public function updateSignalPath(): string + { + return storage_path(self::UPDATE_FILE); + } + + /** + * Request a stack UPDATE by writing the update sentinel. A host-side watcher (the scoped + * clusev-update.path unit) reacts by running update.sh — git pull + idempotent re-install + * (rebuild, migrate, restart) — then removes the sentinel. The container never runs git or + * Docker itself; it only writes this non-sensitive marker. Never throws. + */ + public function requestUpdate(): void + { + $path = $this->updateSignalPath(); + $dir = dirname($path); + + if (! is_dir($dir)) { + @mkdir($dir, 0775, true); + } + + @file_put_contents($path, now()->toIso8601String().PHP_EOL); + } + + /** True while an update request is pending (the host watcher clears it once handled). */ + public function updateRequested(): bool + { + return is_file($this->updateSignalPath()); + } + + /** Remove the update sentinel (tests / manual reset; the host watcher deletes it normally). */ + public function clearUpdateRequest(): void + { + $path = $this->updateSignalPath(); + + if (is_file($path)) { + @unlink($path); + } + } + /** True when the panel domain is a dashboard override (a panel_domain row exists). */ public function domainIsOverridden(): bool { @@ -312,7 +359,7 @@ class DeploymentService * Whether the domain's DNS (A/AAAA) currently resolves to THIS server. * * @return array{ok: ?bool, resolved: string[], serverIp: ?string} - * ok: true = matches, false = clear mismatch, null = unknown (public IP undeterminable) + * ok: true = matches, false = clear mismatch, null = unknown (public IP undeterminable) */ public function domainResolvesHere(string $domain): array { @@ -375,7 +422,7 @@ class DeploymentService * dashboard can show a status without a live (ACME-triggering) probe on every page load. * * @return array{status: string, checkedAt: string, resolved?: string[], serverIp?: ?string} - * status: issued | dns_mismatch | failed | not_applicable + * status: issued | dns_mismatch | failed | not_applicable */ public function requestCertificate(string $domain): array { diff --git a/config/clusev.php b/config/clusev.php index b84438e..0c829c9 100644 --- a/config/clusev.php +++ b/config/clusev.php @@ -3,7 +3,7 @@ return [ // First tagged release is v0.1.0 (semantic, not -dev). The live build hash // is resolved from .git at runtime (see App\Livewire\Versions\Index). - 'version' => '0.9.8', + 'version' => '0.9.9', // Default user channel. Only 'stable' and 'beta' are ever offered to users. 'channel' => 'stable', diff --git a/docker/restart-sentinel/README.md b/docker/restart-sentinel/README.md index 04bc245..298449c 100644 --- a/docker/restart-sentinel/README.md +++ b/docker/restart-sentinel/README.md @@ -33,9 +33,15 @@ restart. The file content is a non-sensitive ISO timestamp; the trigger is the f | File | Role | |---|---| -| `watch.sh` | The actual logic: `once` (systemd) restarts the stack + deletes the sentinel; `loop` is a standalone inotify/poll fallback for hosts without systemd. | -| `clusev-restart.path` | systemd `.path` unit — `PathExists=` the sentinel, triggers the service. | -| `clusev-restart.service` | systemd oneshot — runs `watch.sh once`. | +| `watch.sh` | The actual logic: `once` (systemd) restarts the stack + deletes the sentinel; `update` runs `update.sh` (git pull + re-install); `loop` is a standalone inotify/poll fallback for hosts without systemd (restart only). | +| `clusev-restart.path` | systemd `.path` unit — `PathExists=` the restart sentinel, triggers the restart service. | +| `clusev-restart.service` | systemd oneshot — runs `watch.sh once` as the `clusev` user. | +| `clusev-update.path` | systemd `.path` unit — `PathExists=` the update sentinel (`run/update.request`), triggers the update service. | +| `clusev-update.service` | systemd oneshot — runs `watch.sh update` as **root** (update.sh → install.sh needs root). | + +The update sentinel (dashboard → Version & Releases → "Jetzt aktualisieren") works exactly like the +restart one, but the watcher runs `update.sh` instead of a plain restart, and consumes the sentinel +*before* running so a persistent pull failure can't loop. `install.sh` installs both pairs of units. ## Install (systemd — preferred) diff --git a/docker/restart-sentinel/clusev-update.path b/docker/restart-sentinel/clusev-update.path new file mode 100644 index 0000000..6c1f449 --- /dev/null +++ b/docker/restart-sentinel/clusev-update.path @@ -0,0 +1,24 @@ +# Clusev update-sentinel — path unit (HOST-side). +# +# Watches for the update sentinel the dashboard writes (Versions → "Jetzt aktualisieren") +# and triggers clusev-update.service the moment it appears. Install this unit and +# clusev-update.service into /etc/systemd/system/, then: +# +# systemctl daemon-reload +# systemctl enable --now clusev-update.path +# +# Adjust PathExists= if your project lives somewhere other than /home/nexxo/clusev (it must +# match the ./run bind mount in docker-compose.prod.yml). install.sh rewrites this for you. +[Unit] +Description=Clusev update sentinel — watch for a dashboard update request +After=docker.service +Wants=docker.service + +[Path] +# Fires the .service whenever this file exists. The service consumes the sentinel before +# running update.sh, so a persistent update failure cannot loop here. +PathExists=/home/nexxo/clusev/run/update.request +Unit=clusev-update.service + +[Install] +WantedBy=multi-user.target diff --git a/docker/restart-sentinel/clusev-update.service b/docker/restart-sentinel/clusev-update.service new file mode 100644 index 0000000..c65592a --- /dev/null +++ b/docker/restart-sentinel/clusev-update.service @@ -0,0 +1,28 @@ +# Clusev update-sentinel — service unit (HOST-side). +# +# Triggered by clusev-update.path. Runs watch.sh update: consumes the sentinel, then runs +# update.sh (git pull --ff-only + the idempotent install.sh). A oneshot — it exits when done +# and the .path unit re-arms for the next request. +# +# SECURITY: like the restart unit, this is host-side; the panel container has NO Docker socket +# and only writes the sentinel file. This service runs as ROOT (not the `clusev` user the +# restart unit uses) because update.sh -> install.sh needs root for docker/systemd/apt. It only +# ever runs the project's own committed update.sh, pulling from the configured (trusted) remote. +# +# install.sh rewrites WorkingDirectory= / ExecStart= / Environment=CLUSEV_DIR to the real path. +[Unit] +Description=Clusev update sentinel — pull + re-install the stack on request +After=docker.service +Requires=docker.service + +[Service] +Type=oneshot +User=root +# HOME is needed so update.sh's `git config --global --add safe.directory` lands in root's +# config and the subsequent root-run `git pull` is allowed on the clusev-owned tree. +Environment=HOME=/root +Environment=CLUSEV_DIR=/home/nexxo/clusev +WorkingDirectory=/home/nexxo/clusev +ExecStart=/home/nexxo/clusev/docker/restart-sentinel/watch.sh update +# A full update (pull + image rebuild + migrate) takes longer than a restart. +TimeoutStartSec=900 diff --git a/docker/restart-sentinel/watch.sh b/docker/restart-sentinel/watch.sh index bb9de94..a032fc4 100755 --- a/docker/restart-sentinel/watch.sh +++ b/docker/restart-sentinel/watch.sh @@ -28,10 +28,12 @@ DEFAULT_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)" CLUSEV_DIR="${CLUSEV_DIR:-$DEFAULT_DIR}" CLUSEV_SIGNAL="${CLUSEV_SIGNAL:-${CLUSEV_DIR}/run/restart.request}" +CLUSEV_UPDATE_SIGNAL="${CLUSEV_UPDATE_SIGNAL:-${CLUSEV_DIR}/run/update.request}" CLUSEV_POLL="${CLUSEV_POLL:-5}" COMPOSE_FILE="${CLUSEV_DIR}/docker-compose.prod.yml" -log() { printf '%s clusev-restart: %s\n' "$(date -Is)" "$*"; } +TAG="clusev-restart" +log() { printf '%s %s: %s\n' "$(date -Is)" "$TAG" "$*"; } # Restart the stack and consume the sentinel. `up -d` (not bare `restart`) so a # changed image/config is also applied and any down service is brought back. @@ -59,6 +61,38 @@ run_once() { fi } +# Run update.sh (git pull + idempotent re-install). Unlike restart, the sentinel is consumed +# BEFORE running: a persistent failure (e.g. a non-fast-forwardable pull) must NOT loop the +# .path unit forever — the failure is in the journal and the operator re-triggers from the +# dashboard. Runs as root (the service unit sets User=root) because update.sh -> install.sh +# needs root for docker/systemd/apt. +do_update() { + TAG="clusev-update" + [ -f "$COMPOSE_FILE" ] || { log "compose file not found: $COMPOSE_FILE — skipping"; return 1; } + local updater="${CLUSEV_DIR}/update.sh" + if [ ! -f "$updater" ]; then + log "update.sh not found at $updater — clearing sentinel, nothing to do" + rm -f "$CLUSEV_UPDATE_SIGNAL" + return 1 + fi + rm -f "$CLUSEV_UPDATE_SIGNAL" && log "update sentinel consumed: $CLUSEV_UPDATE_SIGNAL" + log "update requested — running update.sh in ${CLUSEV_DIR}" + if bash "$updater"; then + log "update applied" + else + log "update.sh FAILED — see output above; re-trigger from the dashboard to retry" + return 1 + fi +} + +run_once_update() { + if [ -f "$CLUSEV_UPDATE_SIGNAL" ]; then + do_update + else + TAG="clusev-update"; log "no sentinel at $CLUSEV_UPDATE_SIGNAL — nothing to do" + fi +} + # Fallback loop for hosts without systemd: prefer inotifywait, else poll. run_loop() { log "watching $CLUSEV_SIGNAL (loop mode)" @@ -81,6 +115,7 @@ run_loop() { case "${1:-once}" in once) run_once ;; + update) run_once_update ;; loop) run_loop ;; - *) log "usage: $0 [once|loop]"; exit 64 ;; + *) log "usage: $0 [once|update|loop]"; exit 64 ;; esac diff --git a/install.sh b/install.sh index d49860f..24430df 100755 --- a/install.sh +++ b/install.sh @@ -229,38 +229,45 @@ phase 5/9 "Stack starten" $COMPOSE up -d info "Container gestartet" -# ── restart sentinel (host watcher) — idempotent, best-effort ──────── -# Installs the scoped systemd units so the dashboard's "Jetzt neu starten" button -# works (it writes ./run/restart.request; the host unit restarts the stack). The -# container never gets the Docker socket. Skipped (with a hint) when systemd is -# unavailable — see docker/restart-sentinel/README.md for the manual path. The -# unit runs as the dedicated `clusev` user (a member of the docker group). -install_restart_sentinel() { +# ── host watchers (restart + update sentinels) — idempotent, best-effort ──── +# Installs the scoped systemd units so the dashboard's "Jetzt neu starten" and +# "Jetzt aktualisieren" buttons work (they write ./run/{restart,update}.request; the +# host units act on them). The container never gets the Docker socket. Skipped (with a +# hint) when systemd is unavailable — see docker/restart-sentinel/README.md. The restart +# unit runs as the `clusev` user (docker group); the update unit runs as ROOT, because +# update.sh -> install.sh needs root for docker/systemd/apt. +install_host_watchers() { local src="docker/restart-sentinel" dst="/etc/systemd/system" proj proj="$(pwd)" if ! command -v systemctl >/dev/null 2>&1; then - warn "systemd nicht gefunden — Neustart-Watcher nicht installiert (siehe ${src}/README.md, Loop-Fallback)."; return 0 + warn "systemd nicht gefunden — Host-Watcher nicht installiert (siehe ${src}/README.md, Loop-Fallback)."; return 0 fi # The container's ./run bind mount is owned by clusev; let clusev own it host-side too. mkdir -p ./run chown -R clusev:clusev ./run - # Render the units with THIS project's path + the clusev user (the units in the - # repo default to /home/nexxo/clusev; rewrite to the real deploy location). - local tmp_path tmp_svc - tmp_path="$(mktemp)"; tmp_svc="$(mktemp)" + # Render the units with THIS project's path (the repo units default to /home/nexxo/clusev). + local tmp_path tmp_svc tmp_upath tmp_usvc + tmp_path="$(mktemp)"; tmp_svc="$(mktemp)"; tmp_upath="$(mktemp)"; tmp_usvc="$(mktemp)" + # Restart units run as the clusev user. sed "s#/home/nexxo/clusev#${proj}#g" "${src}/clusev-restart.path" > "$tmp_path" sed -e "s#/home/nexxo/clusev#${proj}#g" -e "s/^User=.*/User=clusev/" "${src}/clusev-restart.service" > "$tmp_svc" + # Update units keep User=root (only the path is rewritten). + sed "s#/home/nexxo/clusev#${proj}#g" "${src}/clusev-update.path" > "$tmp_upath" + sed "s#/home/nexxo/clusev#${proj}#g" "${src}/clusev-update.service" > "$tmp_usvc" if install -m 0644 "$tmp_path" "${dst}/clusev-restart.path" \ && install -m 0644 "$tmp_svc" "${dst}/clusev-restart.service" \ + && install -m 0644 "$tmp_upath" "${dst}/clusev-update.path" \ + && install -m 0644 "$tmp_usvc" "${dst}/clusev-update.service" \ && systemctl daemon-reload \ - && systemctl enable --now clusev-restart.path; then - info "Neustart-Watcher aktiv (clusev-restart.path, User=clusev)" + && systemctl enable --now clusev-restart.path \ + && systemctl enable --now clusev-update.path; then + info "Host-Watcher aktiv (clusev-restart.path User=clusev, clusev-update.path User=root)" else - warn "Neustart-Watcher konnte nicht installiert werden — siehe ${src}/README.md." + warn "Host-Watcher konnten nicht installiert werden — siehe ${src}/README.md." fi - rm -f "$tmp_path" "$tmp_svc" + rm -f "$tmp_path" "$tmp_svc" "$tmp_upath" "$tmp_usvc" } -install_restart_sentinel +install_host_watchers # ── [6/9] wait for the database ────────────────────────────────────── phase 6/9 "Datenbank bereit" diff --git a/lang/de/versions.php b/lang/de/versions.php index f1e8d66..078f8e0 100644 --- a/lang/de/versions.php +++ b/lang/de/versions.php @@ -46,6 +46,13 @@ return [ 'update_title' => 'Aktualisierung', 'update_hint' => 'Updates kommen über getaggte Releases im Deploy — neues Image ziehen und Migrationen anwenden:', + // Dashboard-triggered update (one-click; a host watcher runs update.sh) + 'update_now' => 'Jetzt aktualisieren', + 'update_started' => 'Update gestartet — die Oberfläche ist gleich für ein bis zwei Minuten nicht erreichbar, während der Stack neu gebaut wird. Danach Seite neu laden.', + 'update_running' => 'Update läuft — Stack wird neu gebaut. Bitte gleich neu laden.', + 'update_not_available' => 'Kein Update verfügbar — die installierte Version ist bereits aktuell.', + 'update_throttled' => 'Zu viele Update-Anfragen — bitte in :seconds Sekunden erneut versuchen.', + // Project panel 'project_title' => 'Projekt', 'open_core' => 'Open Core · :license', diff --git a/lang/en/versions.php b/lang/en/versions.php index e646395..b274eed 100644 --- a/lang/en/versions.php +++ b/lang/en/versions.php @@ -46,6 +46,13 @@ return [ 'update_title' => 'Update', 'update_hint' => 'Updates arrive via tagged releases in the deploy — pull the new image and apply migrations:', + // Dashboard-triggered update (one-click; a host watcher runs update.sh) + 'update_now' => 'Update now', + 'update_started' => 'Update started — the interface will be unreachable for a minute or two while the stack rebuilds. Reload the page afterwards.', + 'update_running' => 'Update in progress — the stack is rebuilding. Reload shortly.', + 'update_not_available' => 'No update available — the installed version is already current.', + 'update_throttled' => 'Too many update requests — try again in :seconds seconds.', + // Project panel 'project_title' => 'Project', 'open_core' => 'Open Core · :license', diff --git a/resources/views/livewire/versions/index.blade.php b/resources/views/livewire/versions/index.blade.php index 4bd1029..2e25920 100644 --- a/resources/views/livewire/versions/index.blade.php +++ b/resources/views/livewire/versions/index.blade.php @@ -65,6 +65,20 @@ @endif

+ {{-- One-click update: writes the update sentinel; a host watcher runs update.sh. Mirrors + the System restart button (direct click → "running" notice via a flag — not + data-destructive, so no modal). Only offered once a check found a newer release. --}} + @if ($updateRequested) +

+ {{ __('versions.update_running') }} +

+ @elseif ($isUpdate) + + + + {{ __('versions.update_now') }} + + @endif @if ($lastChecked) {{ __('versions.checked_at', ['time' => $lastChecked]) }} @endif diff --git a/tests/Feature/VersionUpdateCheckTest.php b/tests/Feature/VersionUpdateCheckTest.php index b5ade1c..1795577 100644 --- a/tests/Feature/VersionUpdateCheckTest.php +++ b/tests/Feature/VersionUpdateCheckTest.php @@ -3,11 +3,14 @@ namespace Tests\Feature; use App\Livewire\Versions\Index; +use App\Models\AuditEvent; use App\Models\Setting; use App\Models\User; +use App\Services\DeploymentService; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\RateLimiter; use Livewire\Livewire; use Tests\TestCase; @@ -26,9 +29,16 @@ class VersionUpdateCheckTest extends TestCase { parent::setUp(); Cache::flush(); // never inherit a cached latest-release between tests + app(DeploymentService::class)->clearUpdateRequest(); // never inherit a stray sentinel $this->actingAs(User::factory()->create(['must_change_password' => false])); } + protected function tearDown(): void + { + app(DeploymentService::class)->clearUpdateRequest(); // leave the storage dir clean + parent::tearDown(); + } + public function test_reports_update_available_from_the_remote_tag_api(): void { config()->set('clusev.version', '0.9.4'); @@ -84,4 +94,55 @@ class VersionUpdateCheckTest extends TestCase ->assertSet('updateState', 'current') ->assertSet('lastChecked', now()->format('H:i')); } + + public function test_request_update_writes_the_sentinel_and_audits_when_available(): void + { + config()->set('clusev.version', '0.9.4'); + Http::fake([self::TAGS_URL => Http::response([['name' => 'v0.9.6']])]); + + Livewire::test(Index::class) + ->call('requestUpdate') + ->assertSet('updateRequested', true); + + $this->assertTrue( + app(DeploymentService::class)->updateRequested(), + 'the update sentinel a host watcher reacts to must exist', + ); + $this->assertTrue(AuditEvent::where('action', 'deploy.update_request')->exists()); + } + + public function test_request_update_refuses_when_already_current(): void + { + config()->set('clusev.version', '0.9.9'); + Http::fake([self::TAGS_URL => Http::response([['name' => 'v0.9.6']])]); + + Livewire::test(Index::class) + ->call('requestUpdate') + ->assertSet('updateRequested', false) + ->assertSet('updateState', 'current'); + + $this->assertFalse( + app(DeploymentService::class)->updateRequested(), + 'no sentinel when there is nothing newer to apply', + ); + } + + public function test_request_update_is_throttled_per_user(): void + { + config()->set('clusev.version', '0.9.4'); + Http::fake([self::TAGS_URL => Http::response([['name' => 'v0.9.6']])]); + $key = 'update-request:'.auth()->id(); + for ($i = 0; $i < 3; $i++) { + RateLimiter::hit($key, 600); + } + + Livewire::test(Index::class) + ->call('requestUpdate') + ->assertSet('updateRequested', false); + + $this->assertFalse( + app(DeploymentService::class)->updateRequested(), + 'the per-user throttle must block the sentinel write', + ); + } }