From c7dcd90b834095ea0b65f47f78b0c0c3907d8122 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 8 Jul 2026 08:35:46 +0100 Subject: [PATCH] Surface agent auth failures and staleness instead of a silent 401 loop Refs #1515 When an upgraded or restored Pulse server no longer recognises an agent's API token, the report endpoint returns 401. The agent buffered and retried that report forever with only a generic warning, and the server kept the node green at its last known agent version because a Proxmox node stays online via the PVE API poll even after its agent dies. - Agent: special-case 401 on /api/agents/agent/report. Drop the report instead of buffering it and log a throttled, actionable error pointing the operator at the install command to mint a fresh token. - Installer: verify_agent_server_registration now tells a rejected token (401/403) apart from "agent has not reported yet" and prints the recovery steps at install time instead of a vague soft warning. - Status layer: resourceFromHost flags a stale agent (its host marked offline by the staleness evaluator) and carries the agent's own last report time. Coalescing keeps a node online via the PVE source but the dead agent stays flagged, so its version is no longer presented as current. The Machines table renders such versions as "(stale)". --- .../standalone/AgentsMachinesTable.tsx | 20 +++- .../__tests__/AgentsMachinesTable.test.tsx | 25 +++++ frontend-modern/src/types/resource.ts | 9 ++ internal/hostagent/agent.go | 60 +++++++++++ internal/hostagent/send_report_test.go | 102 ++++++++++++++++++ internal/unifiedresources/adapters.go | 14 +++ internal/unifiedresources/adapters_test.go | 48 +++++++++ .../presentation_coalesce_test.go | 49 +++++++++ internal/unifiedresources/types.go | 15 ++- scripts/install.sh | 47 ++++++-- scripts/installtests/install_sh_test.go | 83 ++++++++++++++ 11 files changed, 463 insertions(+), 9 deletions(-) diff --git a/frontend-modern/src/features/standalone/AgentsMachinesTable.tsx b/frontend-modern/src/features/standalone/AgentsMachinesTable.tsx index da74591b8..c056f0130 100644 --- a/frontend-modern/src/features/standalone/AgentsMachinesTable.tsx +++ b/frontend-modern/src/features/standalone/AgentsMachinesTable.tsx @@ -1427,6 +1427,15 @@ export const AgentsMachinesTable: Component<{ asTrimmedString(machine.identity?.hostname); const systemLabel = () => systemLabelFor(machine); const agentVersion = () => agentVersionFor(machine); + const agentStale = () => + !isAgentlessMachine(machine) && Boolean(machine.agent?.stale); + const agentStaleTitle = () => { + const last = asTrimmedString(machine.agent?.lastReportAt); + const when = last + ? ` Last report ${formatPlatformTableRelativeTimeValue(last)}.` + : ''; + return `Agent has stopped reporting.${when} Re-run the install command from Settings > Agents to refresh its token.`; + }; const indicator = () => getSimpleStatusIndicator(machine.status); const canRenderMetrics = () => indicator().variant !== 'danger'; const telemetryFallback = () => @@ -1589,8 +1598,17 @@ export const AgentsMachinesTable: Component<{ - + {agentVersion()} + + (stale) + diff --git a/frontend-modern/src/features/standalone/__tests__/AgentsMachinesTable.test.tsx b/frontend-modern/src/features/standalone/__tests__/AgentsMachinesTable.test.tsx index 617f9d356..8a0a29994 100644 --- a/frontend-modern/src/features/standalone/__tests__/AgentsMachinesTable.test.tsx +++ b/frontend-modern/src/features/standalone/__tests__/AgentsMachinesTable.test.tsx @@ -299,6 +299,31 @@ describe('AgentsMachinesTable', () => { ).toBeGreaterThanOrEqual(2); }); + it('flags a non-reporting agent version as stale even when the row stays online', () => { + render(() => ( + + )); + + expect(screen.getByText('(stale)')).toBeInTheDocument(); + expect(screen.getByTitle(/Agent has stopped reporting/)).toBeInTheDocument(); + }); + it('renders multi-disk machine usage as vertical mini-bars', () => { const { container } = render(() => ( v6 upgrade that did + // not carry the token across). Retrying with the same token loops + // forever, so drop the report instead of buffering it and surface an + // actionable error the operator can act on. Throttle it so a + // permanently rejected token does not flood the log. + a.logAuthFailure(statusErr) + return nil + } a.reportBuffer.Push(report) event := a.logger.Warn(). @@ -567,6 +586,10 @@ func (a *Agent) process(ctx context.Context) error { return nil } + // A successful report means the token is accepted again; reset the auth + // failure throttle so a later rejection is reported promptly. + a.lastAuthFailureLog = time.Time{} + // If successful, try to flush buffer a.flushBuffer(ctx) @@ -577,6 +600,31 @@ func (a *Agent) process(ctx context.Context) error { return nil } +// logAuthFailure emits an actionable, throttled error when the server rejects +// the agent's API token with 401. It is only called from the single-threaded +// report loop, so lastAuthFailureLog needs no additional synchronisation. +func (a *Agent) logAuthFailure(statusErr *reportHTTPStatusError) { + if time.Since(a.lastAuthFailureLog) < authFailureLogInterval { + return + } + a.lastAuthFailureLog = time.Now() + + endpoint := agentReportEndpoint + statusCode := http.StatusUnauthorized + if statusErr != nil { + if statusErr.Endpoint != "" { + endpoint = statusErr.Endpoint + } + statusCode = statusErr.StatusCode + } + + a.logger.Error(). + Str("endpoint", endpoint). + Int("status_code", statusCode). + Str("pulse_url", a.trimmedPulseURL). + Msg("Pulse rejected this agent's API token (401 Unauthorized). The token is no longer valid on the server, which usually means Pulse was restored/reinstalled or upgraded (for example v5 -> v6) without carrying the token across. Re-run the agent install command from the Pulse UI (Settings > Agents) to mint a fresh token. Reports are dropped until the token is replaced.") +} + func (a *Agent) flushBuffer(ctx context.Context) { if a.reportBuffer.IsEmpty() { return @@ -593,6 +641,18 @@ func (a *Agent) flushBuffer(ctx context.Context) { if err := a.sendReport(ctx, report); err != nil { var statusErr *reportHTTPStatusError + if errors.As(err, &statusErr) && statusErr.StatusCode == http.StatusUnauthorized { + // The token is rejected; buffered reports will never be + // accepted with it. Drop them so the buffer cannot grow + // unbounded across restarts, and surface the actionable error. + a.logAuthFailure(statusErr) + for { + if _, ok := a.reportBuffer.Pop(); !ok { + break + } + } + return + } event := a.logger.Warn(). Err(err). Str("endpoint", agentReportEndpoint). diff --git a/internal/hostagent/send_report_test.go b/internal/hostagent/send_report_test.go index 583f3ef64..97c77ad64 100644 --- a/internal/hostagent/send_report_test.go +++ b/internal/hostagent/send_report_test.go @@ -12,6 +12,7 @@ import ( "strings" "sync" "testing" + "time" "github.com/rcourtman/pulse-go-rewrite/internal/utils" agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host" @@ -239,6 +240,107 @@ func TestAgentProcess_ForbiddenResponseDoesNotBuffer(t *testing.T) { } } +func TestAgentProcess_UnauthorizedResponseDoesNotBuffer(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer server.Close() + + var logBuf bytes.Buffer + agent := &Agent{ + cfg: Config{APIToken: "stale-token"}, + logger: zerolog.New(&logBuf), + httpClient: server.Client(), + trimmedPulseURL: server.URL, + reportBuffer: utils.New[agentshost.Report](8), + collector: &mockCollector{}, + } + + if err := agent.process(context.Background()); err != nil { + t.Fatalf("process: %v", err) + } + // A rejected token loops forever; the report must be dropped, not buffered. + if !agent.reportBuffer.IsEmpty() { + t.Fatalf("buffer should stay empty for unauthorized response, len=%d", agent.reportBuffer.Len()) + } + if !strings.Contains(logBuf.String(), "rejected this agent's API token") { + t.Fatalf("expected 401 log to be actionable, got %q", logBuf.String()) + } +} + +func TestAgentProcess_UnauthorizedLogThrottled(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer server.Close() + + var logBuf bytes.Buffer + agent := &Agent{ + cfg: Config{APIToken: "stale-token"}, + logger: zerolog.New(&logBuf), + httpClient: server.Client(), + trimmedPulseURL: server.URL, + reportBuffer: utils.New[agentshost.Report](8), + collector: &mockCollector{}, + } + + for i := 0; i < 3; i++ { + if err := agent.process(context.Background()); err != nil { + t.Fatalf("process %d: %v", i, err) + } + } + if got := strings.Count(logBuf.String(), "rejected this agent's API token"); got != 1 { + t.Fatalf("expected actionable 401 log exactly once (throttled), got %d in %q", got, logBuf.String()) + } + + // Resetting the throttle (which a successful report does) re-emits the error. + agent.lastAuthFailureLog = time.Time{} + if err := agent.process(context.Background()); err != nil { + t.Fatalf("process after reset: %v", err) + } + if got := strings.Count(logBuf.String(), "rejected this agent's API token"); got != 2 { + t.Fatalf("expected actionable 401 log to re-emit after throttle reset, got %d", got) + } +} + +func TestAgentFlushBuffer_UnauthorizedDropsBuffer(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + defer server.Close() + + var logBuf bytes.Buffer + buffer := utils.New[agentshost.Report](8) + buffer.Push(agentshost.Report{Agent: agentshost.AgentInfo{ID: "a1"}}) + buffer.Push(agentshost.Report{Agent: agentshost.AgentInfo{ID: "a2"}}) + + agent := &Agent{ + cfg: Config{APIToken: "stale-token"}, + logger: zerolog.New(&logBuf), + httpClient: server.Client(), + trimmedPulseURL: server.URL, + reportBuffer: buffer, + collector: &mockCollector{}, + } + + agent.flushBuffer(context.Background()) + + // Buffered reports will never be accepted with a rejected token; drop them + // so the buffer cannot grow unbounded across restarts. + if !agent.reportBuffer.IsEmpty() { + t.Fatalf("buffer should be drained after unauthorized flush, len=%d", agent.reportBuffer.Len()) + } + if !strings.Contains(logBuf.String(), "rejected this agent's API token") { + t.Fatalf("expected flush 401 log to be actionable, got %q", logBuf.String()) + } +} + func TestAgentProcess_SuccessLogsUnifiedAgentReport(t *testing.T) { t.Parallel() diff --git a/internal/unifiedresources/adapters.go b/internal/unifiedresources/adapters.go index 2340410f8..aefec4fd6 100644 --- a/internal/unifiedresources/adapters.go +++ b/internal/unifiedresources/adapters.go @@ -165,6 +165,20 @@ func resourceFromHost(host models.Host) (Resource, ResourceIdentity) { LinkedVMID: host.LinkedVMID, LinkedContainerID: host.LinkedContainerID, } + + // Surface agent staleness so a row that stays online via another source + // (e.g. a Proxmox node still reachable over the PVE API) does not present a + // dead agent's version as if it were current. The staleness evaluator marks + // a host "offline" once it stops reporting; carry that onto the agent + // payload along with the agent's own last report time. + if !host.LastSeen.IsZero() { + lastReport := host.LastSeen + agent.LastReportAt = &lastReport + } + if strings.EqualFold(strings.TrimSpace(host.Status), "offline") { + agent.Stale = true + } + storageAssessments := make([]storagehealth.Assessment, 0, len(host.RAID)+1) // Populate sensors diff --git a/internal/unifiedresources/adapters_test.go b/internal/unifiedresources/adapters_test.go index 04f1511e6..3e1659cb2 100644 --- a/internal/unifiedresources/adapters_test.go +++ b/internal/unifiedresources/adapters_test.go @@ -579,6 +579,54 @@ func TestResourceFromHostProjectsAgentHostProfile(t *testing.T) { } } +func TestResourceFromHostMarksOfflineAgentStale(t *testing.T) { + lastReport := time.Now().Add(-30 * time.Minute).UTC() + host := models.Host{ + ID: "omv-host", + Hostname: "omv", + Platform: "linux", + Status: "offline", // set by the staleness evaluator when reports stop + AgentVersion: "6.0.2", + LastSeen: lastReport, + } + + resource, _ := resourceFromHost(host) + if resource.Agent == nil { + t.Fatal("expected agent payload") + } + if !resource.Agent.Stale { + t.Fatalf("expected offline agent to be marked stale, got %+v", resource.Agent) + } + if resource.Agent.AgentVersion != "6.0.2" { + t.Fatalf("agent version = %q, want the last reported 6.0.2 retained", resource.Agent.AgentVersion) + } + if resource.Agent.LastReportAt == nil || !resource.Agent.LastReportAt.Equal(lastReport) { + t.Fatalf("expected agent LastReportAt = %v, got %v", lastReport, resource.Agent.LastReportAt) + } +} + +func TestResourceFromHostOnlineAgentNotStale(t *testing.T) { + host := models.Host{ + ID: "live-host", + Hostname: "live", + Platform: "linux", + Status: "online", + AgentVersion: "6.0.5", + LastSeen: time.Now().UTC(), + } + + resource, _ := resourceFromHost(host) + if resource.Agent == nil { + t.Fatal("expected agent payload") + } + if resource.Agent.Stale { + t.Fatalf("online agent should not be marked stale, got %+v", resource.Agent) + } + if resource.Agent.LastReportAt == nil { + t.Fatalf("expected LastReportAt to be populated for a reporting agent") + } +} + func TestResourceFromHostProjectsPressureOnlyThermalState(t *testing.T) { warningLevel := 1 host := models.Host{ diff --git a/internal/unifiedresources/presentation_coalesce_test.go b/internal/unifiedresources/presentation_coalesce_test.go index ee331e14d..8a0b48116 100644 --- a/internal/unifiedresources/presentation_coalesce_test.go +++ b/internal/unifiedresources/presentation_coalesce_test.go @@ -58,6 +58,55 @@ func TestCoalescePresentationHostResourcesMergesSplitRuntimeAndPlatformHost(t *t } } +func TestCoalescePresentationHostResourcesSurfacesStaleAgentOnLiveNode(t *testing.T) { + // A Proxmox node whose Pulse Agent has stopped reporting (401) stays online + // via the PVE API poll, but the dead agent must remain flagged stale so the + // UI does not present its last version as if it were current (issue #1515). + now := time.Date(2026, 7, 8, 10, 30, 0, 0, time.UTC) + resources := []Resource{ + { + ID: "agent-runtime-pve1", + Type: ResourceTypeAgent, + Name: "pve1", + Status: StatusOffline, + LastSeen: now.Add(-30 * time.Minute), + Sources: []DataSource{SourceAgent}, + Identity: ResourceIdentity{Hostnames: []string{"pve1"}}, + Agent: &AgentData{ + AgentID: "agent-machine-pve1", + Hostname: "pve1", + AgentVersion: "6.0.2", + Stale: true, + }, + }, + { + ID: "proxmox-node-pve1", + Type: ResourceTypeAgent, + Name: "pve1", + Status: StatusOnline, + LastSeen: now, + Sources: []DataSource{SourceProxmox}, + Identity: ResourceIdentity{Hostnames: []string{"pve1"}}, + Proxmox: &ProxmoxData{NodeName: "pve1", ClusterName: "homelab"}, + }, + } + + coalesced := CoalescePresentationHostResources(resources) + if len(coalesced) != 1 { + t.Fatalf("expected split host resources to coalesce into 1 resource, got %d", len(coalesced)) + } + resource := coalesced[0] + if resource.Status != StatusOnline { + t.Fatalf("node should stay online via the PVE source, got %q", resource.Status) + } + if resource.Agent == nil || !resource.Agent.Stale { + t.Fatalf("expected the dead agent to remain flagged stale after merge, got %+v", resource.Agent) + } + if resource.Agent.AgentVersion != "6.0.2" { + t.Fatalf("expected stale agent version retained, got %q", resource.Agent.AgentVersion) + } +} + func TestCoalescePresentationHostResourcesRedirectsProxmoxChildrenToAgentBackedParent(t *testing.T) { now := time.Date(2026, 5, 22, 10, 30, 0, 0, time.UTC) proxmoxParentID := "agent-proxmox-delly" diff --git a/internal/unifiedresources/types.go b/internal/unifiedresources/types.go index 63a3bf12c..7366e9d4a 100644 --- a/internal/unifiedresources/types.go +++ b/internal/unifiedresources/types.go @@ -774,8 +774,19 @@ type AgentMemoryMeta struct { // AgentData contains host agent-specific data. type AgentData struct { - AgentID string `json:"agentId,omitempty"` - AgentVersion string `json:"agentVersion,omitempty"` + AgentID string `json:"agentId,omitempty"` + AgentVersion string `json:"agentVersion,omitempty"` + // Stale is set when the agent has stopped reporting (its host was marked + // offline by the staleness evaluator) even though the row itself may stay + // online via another source such as the Proxmox API poll. It lets the UI + // present the agent and its version as not-reporting instead of a + // healthy-looking stale value. + Stale bool `json:"stale,omitempty"` + // LastReportAt is the agent's own last successful report time. On a + // multi-source row (for example a Proxmox node also polled over the PVE + // API) this differs from the row's LastSeen, which reflects the freshest + // source rather than the agent. + LastReportAt *time.Time `json:"lastReportAt,omitempty"` Hostname string `json:"hostname,omitempty"` MachineID string `json:"machineId,omitempty"` TokenID string `json:"tokenId,omitempty"` diff --git a/scripts/install.sh b/scripts/install.sh index 56b7ed506..736dba27d 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -347,10 +347,27 @@ restore_selinux_contexts() { # After starting the agent service, poll its readiness endpoint to verify it # actually started. The agent exposes /readyz on the configured health address # once modules are initialized. The default is 127.0.0.1:9191. +# warn_agent_token_rejected surfaces the actionable recovery path when the +# server rejects the agent's token. Keeps the message in one place so both +# health-check paths report it identically. +warn_agent_token_rejected() { + log_warn "Pulse rejected this agent's API token (HTTP 401/403). The saved token is no longer valid on the server, which usually means Pulse was restored/reinstalled or upgraded (for example v5 -> v6) without carrying the token across." + log_warn "Re-run the full agent install command from the Pulse UI (Settings > Agents) to mint a fresh token. The agent will keep reporting 401 until the token is replaced." +} + +# verify_agent_server_registration returns: +# 0 - the server confirmed this agent's registration +# 1 - registration not confirmed yet (transient: agent not reported, network) +# 2 - the server rejected the token (HTTP 401/403 - actionable, permanent) verify_agent_server_registration() { local lookup_hostname="${HOSTNAME_OVERRIDE}" - local lookup_resp="" - local lookup_args=(-fsSL --connect-timeout 5 --max-time 10) + local lookup_out="" + local lookup_status="" + local lookup_body="" + # No -f: we need the response body AND the HTTP status even on 4xx so a + # rejected token (401/403) can be told apart from "not reported yet". The + # -w format appends "\n" after the body. + local lookup_args=(-sSL --connect-timeout 5 --max-time 10 -w $'\n%{http_code}') if [[ -z "$PULSE_URL" ]]; then return 1 @@ -366,8 +383,18 @@ verify_agent_server_registration() { if [[ "$INSECURE" == "true" ]]; then lookup_args+=(-k); fi if [[ -n "$CURL_CA_BUNDLE" ]]; then lookup_args+=(--cacert "$CURL_CA_BUNDLE"); fi - lookup_resp=$(curl "${lookup_args[@]}" "${PULSE_URL}/api/agents/agent/lookup?hostname=$(url_encode "$lookup_hostname")" 2>/dev/null || true) - if echo "$lookup_resp" | grep -q '"agent"[[:space:]]*:' && echo "$lookup_resp" | grep -q '"id"[[:space:]]*:'; then + lookup_out=$(curl "${lookup_args[@]}" "${PULSE_URL}/api/agents/agent/lookup?hostname=$(url_encode "$lookup_hostname")" 2>/dev/null || true) + lookup_status="${lookup_out##*$'\n'}" + lookup_body="${lookup_out%$'\n'*}" + + # A rejected token is a definitive, actionable failure distinct from "the + # agent has not reported yet"; signal it so the caller can tell the operator + # the token needs replacing instead of leaving a silent 401 loop behind. + case "$lookup_status" in + 401|403) return 2 ;; + esac + + if echo "$lookup_body" | grep -q '"agent"[[:space:]]*:' && echo "$lookup_body" | grep -q '"id"[[:space:]]*:'; then return 0 fi return 1 @@ -429,8 +456,12 @@ verify_agent_started() { if [[ -z "$health_url" ]]; then while [ $iteration -lt $max_iterations ]; do if agent_process_running; then - if verify_agent_server_registration; then + verify_agent_server_registration + local reg_rc=$? + if [[ $reg_rc -eq 0 ]]; then log_info "Agent process is running and registered with Pulse." + elif [[ $reg_rc -eq 2 ]]; then + warn_agent_token_rejected else log_warn "Agent process is running, but server registration was not confirmed yet." fi @@ -451,8 +482,12 @@ verify_agent_started() { while [ $iteration -lt $max_iterations ]; do # Check the readiness endpoint first — this is the definitive signal if curl -sf --max-time 2 "$health_url" >/dev/null 2>&1; then - if verify_agent_server_registration; then + verify_agent_server_registration + local reg_rc=$? + if [[ $reg_rc -eq 0 ]]; then log_info "Agent is running, healthy, and registered with Pulse." + elif [[ $reg_rc -eq 2 ]]; then + warn_agent_token_rejected else log_warn "Agent local health is ready, but server registration was not confirmed yet." fi diff --git a/scripts/installtests/install_sh_test.go b/scripts/installtests/install_sh_test.go index fc9cf553e..b460c4424 100644 --- a/scripts/installtests/install_sh_test.go +++ b/scripts/installtests/install_sh_test.go @@ -3517,3 +3517,86 @@ func TestSetupAutoUpdatesPreservesRCChannelWhenUpdatingExistingConfig(t *testing t.Fatalf("system.json lost rc channel:\n%s", content) } } + +// TestInstallSHVerifyAgentServerRegistrationDetectsRejectedToken verifies that +// the post-install health check tells a rejected token (401/403) apart from a +// transient "agent has not reported yet" state, so the installer can surface an +// actionable error instead of leaving a silent 401 loop behind (issue #1515). +func TestInstallSHVerifyAgentServerRegistrationDetectsRejectedToken(t *testing.T) { + urlEncode := extractInstallShellFunction(t, "url_encode") + verifyFn := extractInstallShellFunction(t, "verify_agent_server_registration") + + cases := []struct { + name string + status int + body string + wantRC string + }{ + {"rejected token 401", http.StatusUnauthorized, `{"error":"Authentication required"}`, "rc=2"}, + {"rejected token 403", http.StatusForbidden, `{"error":"missing_scope"}`, "rc=2"}, + {"registered", http.StatusOK, `{"success":true,"agent":{"id":"agent-omv"}}`, "rc=0"}, + {"not reported yet", http.StatusNotFound, `{"error":"agent_not_found"}`, "rc=1"}, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.URL.Path, "/api/agents/agent/lookup") { + http.Error(w, "not found", http.StatusNotFound) + return + } + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(tc.body)) + })) + defer server.Close() + + script := ` + PULSE_URL="` + server.URL + `" + PULSE_TOKEN="stale-token" + HOSTNAME_OVERRIDE="omv" + INSECURE="false" + CURL_CA_BUNDLE="" +` + urlEncode + ` +` + verifyFn + ` + verify_agent_server_registration + echo "rc=$?" + ` + out, err := exec.Command("bash", "-c", script).CombinedOutput() + if err != nil { + t.Fatalf("bash: %v\n%s", err, out) + } + if !strings.Contains(string(out), tc.wantRC) { + t.Fatalf("case %q: want %s, got:\n%s", tc.name, tc.wantRC, out) + } + }) + } +} + +// TestInstallSHWarnAgentTokenRejectedIsActionable pins the actionable recovery +// copy the installer prints when the server rejects the agent's token. +func TestInstallSHWarnAgentTokenRejectedIsActionable(t *testing.T) { + logWarn := extractInstallShellFunction(t, "log_warn") + warnFn := extractInstallShellFunction(t, "warn_agent_token_rejected") + + script := ` + NON_INTERACTIVE="false" + redact_token() { printf '%s' "$1"; } +` + logWarn + ` +` + warnFn + ` + warn_agent_token_rejected + ` + out, err := exec.Command("bash", "-c", script).CombinedOutput() + if err != nil { + t.Fatalf("bash: %v\n%s", err, out) + } + for _, needle := range []string{ + "rejected this agent's API token", + "Re-run the full agent install command", + "mint a fresh token", + } { + if !strings.Contains(string(out), needle) { + t.Fatalf("warn_agent_token_rejected missing %q:\n%s", needle, out) + } + } +}