Files
pulse/internal/api/configapi/node_test_tally_handler_test.go
rcourtman 9e37d629ac Measure node connection test outcomes
Telemetry could see only saved connections, so an install that tried to
reach a node and could not was indistinguishable from one that never
opened the add-node dialog. Both report zero configured connections and
stall at the same activation stage. Fleet data shows that population is
real and concentrated three to one in container deployments, and nothing
recorded whether those installs attempted a connection at all.

Record node connection test attempts and failures in a bounded,
day-bucketed tally in the config directory, pruned to a 31-day retention
window, and report both over the install-ID rotation window as
node_test_attempts_30d and node_test_failures_30d.

Recording starts only once a request carries a target and credentials, so
an incomplete form is never counted as a node that could not be reached.
A host string that turns out to be unusable does count, because the
attempt was made and it failed. Only the add-node dialog endpoint is
instrumented: instrumenting the unused test-config endpoint as well would
double-count a single operator action.

The tally holds counts alone. Hosts, credentials, and error text never
enter it, which is why it is plain JSON rather than encrypted history.
2026-08-24 10:17:17 +01:00

119 lines
3.7 KiB
Go

package configapi
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
func nodeTestTallyCounts(t *testing.T, dataPath string) (attempts int, failures int) {
t.Helper()
tally, err := config.NewConfigPersistence(dataPath).LoadNodeTestTally()
if err != nil {
t.Fatalf("LoadNodeTestTally: %v", err)
}
since := time.Now().UTC().AddDate(0, 0, -30)
return tally.AttemptsSince(since), tally.FailuresSince(since)
}
func postNodeConnectionTest(t *testing.T, h *ConfigHandlers, body map[string]string) {
t.Helper()
encoded, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal request: %v", err)
}
req := httptest.NewRequest("POST", "/api/config/nodes/test-connection", bytes.NewBuffer(encoded))
h.HandleTestConnection(httptest.NewRecorder(), req)
}
// A request that never carried a target and credentials was not an attempt to
// reach a node, so counting it would inflate the failure share this counter
// exists to measure.
func TestNodeTestTallyIgnoresRequestsWithoutATarget(t *testing.T) {
dataPath := t.TempDir()
h := newTestConfigHandlers(t, &config.Config{DataPath: dataPath})
for _, body := range []map[string]string{
{"type": "pve"},
{"type": "pve", "host": "10.0.0.1"},
{"type": "unknown", "host": "10.0.0.1", "user": "root@pam", "password": "x"},
} {
postNodeConnectionTest(t, h, body)
}
attempts, failures := nodeTestTallyCounts(t, dataPath)
if attempts != 0 || failures != 0 {
t.Fatalf("targetless requests tallied: attempts=%d failures=%d, want 0 and 0", attempts, failures)
}
}
// The other side of that boundary: someone who typed an unusable host and got
// an error did attempt to reach a node, and that attempt failed. Excluding it
// would hide exactly the population this counter exists to find.
func TestNodeTestTallyCountsUnusableHostAsFailedAttempt(t *testing.T) {
dataPath := t.TempDir()
h := newTestConfigHandlers(t, &config.Config{DataPath: dataPath})
postNodeConnectionTest(t, h, map[string]string{
"type": "pve",
"host": "://invalid-url",
"user": "root@pam",
"password": "password",
})
attempts, failures := nodeTestTallyCounts(t, dataPath)
if attempts != 1 || failures != 1 {
t.Fatalf("attempts=%d failures=%d, want 1 and 1", attempts, failures)
}
}
// An unreachable target is the case the counter exists for. proxmox.NewClient
// authenticates eagerly, so this fails during client construction rather than
// on a later call, and it must still be counted.
func TestNodeTestTallyCountsUnreachableTargetAsFailure(t *testing.T) {
dataPath := t.TempDir()
h := newTestConfigHandlers(t, &config.Config{DataPath: dataPath})
postNodeConnectionTest(t, h, map[string]string{
"type": "pve",
"host": "127.0.0.1:1",
"user": "root@pam",
"password": "password",
})
attempts, failures := nodeTestTallyCounts(t, dataPath)
if attempts != 1 {
t.Fatalf("attempts = %d, want 1", attempts)
}
if failures != 1 {
t.Fatalf("failures = %d, want 1", failures)
}
}
// The success paths write a body without setting a status explicitly, so the
// recorder must treat an unset status as success rather than failure. Driving a
// real successful test would need a live Proxmox endpoint, so the mapping is
// asserted directly.
func TestNodeTestOutcomeRecorderTreatsImplicitStatusAsSuccess(t *testing.T) {
rec := newNodeTestOutcomeRecorder(httptest.NewRecorder())
if rec.failed() {
t.Fatal("implicit 200 reported as failure")
}
if _, err := rec.Write([]byte(`{"status":"success"}`)); err != nil {
t.Fatalf("write: %v", err)
}
if rec.failed() {
t.Fatal("body write without WriteHeader reported as failure")
}
rec.WriteHeader(http.StatusBadRequest)
if !rec.failed() {
t.Fatal("400 not reported as failure")
}
}