Files
pulse/internal/api/system_settings_telemetry_test.go
T
courtmanr@gmail.com 893aa0b2cd Clear temperature SSH failure backoff on system-settings save (#1638)
The reset added in b45bd66b9 only fired when the temperature SSH key
file on disk changed (mtime/size). An operator who repairs SSH access
any other way — fixing authorized_keys on the host, repairing
known_hosts, restoring network reachability — still waited out a
backoff window that may have compounded toward fifteen minutes.

A system-settings save is the natural operator touchpoint after such a
repair, so the settings handler now fans ResetSSHFailureBackoff out to
every live tenant monitor after a successful save, clearing the
per-host temperature SSH backoff and the knownhosts keyscan backoff.
The reset touches in-memory retry timing only; nothing is persisted
and no request field controls it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 13:28:55 +01:00

551 lines
18 KiB
Go

package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/telemetry"
internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
)
// setupTelemetryTest creates a handler with API token auth configured.
func setupTelemetryTest(t *testing.T, cfg *config.Config) (*SystemSettingsHandler, *config.ConfigPersistence, string) {
t.Helper()
persistence := config.NewConfigPersistence(cfg.DataPath)
tokenVal := "telemetry-test-token-123.12345678"
tokenHash := internalauth.HashAPIToken(tokenVal)
cfg.APITokens = []config.APITokenRecord{
{ID: "tok1", Hash: tokenHash, Name: "Test Token"},
}
handler := newTestSystemSettingsHandler(cfg, persistence, &mockMonitor{}, func() {}, func() error { return nil })
return handler, persistence, tokenVal
}
func TestTelemetryUpdate_EnvLockRejects(t *testing.T) {
tempDir := t.TempDir()
cfg := &config.Config{
DataPath: tempDir,
ConfigPath: tempDir,
TelemetryEnabled: true,
EnvOverrides: map[string]bool{"PULSE_TELEMETRY": true, "telemetryEnabled": true},
}
handler, persistence, token := setupTelemetryTest(t, cfg)
initial := config.DefaultSystemSettings()
if err := persistence.SaveSystemSettings(*initial); err != nil {
t.Fatal(err)
}
body, _ := json.Marshal(map[string]interface{}{"telemetryEnabled": false})
req := httptest.NewRequest(http.MethodPost, "/api/system-settings", bytes.NewReader(body))
req.Header.Set("X-API-Token", token)
rec := httptest.NewRecorder()
handler.HandleUpdateSystemSettings(rec, req)
if rec.Code != http.StatusConflict {
t.Fatalf("expected 409 Conflict when env-locked, got %d: %s", rec.Code, rec.Body.String())
}
// Verify in-memory config was NOT changed.
if !cfg.TelemetryEnabled {
t.Error("TelemetryEnabled should still be true after env-lock rejection")
}
}
func TestTelemetryUpdate_NullIsIgnored(t *testing.T) {
tempDir := t.TempDir()
cfg := &config.Config{
DataPath: tempDir,
ConfigPath: tempDir,
TelemetryEnabled: true,
EnvOverrides: make(map[string]bool),
}
handler, persistence, token := setupTelemetryTest(t, cfg)
initial := config.DefaultSystemSettings()
enabled := true
initial.TelemetryEnabled = &enabled
if err := persistence.SaveSystemSettings(*initial); err != nil {
t.Fatal(err)
}
// Send telemetryEnabled: null via raw JSON.
body := []byte(`{"telemetryEnabled": null}`)
req := httptest.NewRequest(http.MethodPost, "/api/system-settings", bytes.NewReader(body))
req.Header.Set("X-API-Token", token)
rec := httptest.NewRecorder()
handler.HandleUpdateSystemSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
// In-memory should remain true (null should not flip it).
if !cfg.TelemetryEnabled {
t.Error("TelemetryEnabled should still be true after null update")
}
// Verify persisted value is still set (not nil).
saved, err := persistence.LoadSystemSettings()
if err != nil {
t.Fatal(err)
}
if saved.TelemetryEnabled == nil {
t.Error("persisted TelemetryEnabled should not be nil after null update")
} else if !*saved.TelemetryEnabled {
t.Error("persisted TelemetryEnabled should still be true")
}
}
func TestTelemetryUpdate_PersistBeforeMutate(t *testing.T) {
tempDir := t.TempDir()
cfg := &config.Config{
DataPath: tempDir,
ConfigPath: tempDir,
TelemetryEnabled: true,
PVEPollingInterval: 10 * time.Second,
EnvOverrides: make(map[string]bool),
}
saveCalled := false
persistence := config.NewConfigPersistence(cfg.DataPath)
tokenVal := "telemetry-test-token-123.12345678"
tokenHash := internalauth.HashAPIToken(tokenVal)
cfg.APITokens = []config.APITokenRecord{
{ID: "tok1", Hash: tokenHash, Name: "Test Token"},
}
handler := newTestSystemSettingsHandler(cfg, persistence, &mockMonitor{}, func() {
saveCalled = true
}, func() error { return nil })
initial := config.DefaultSystemSettings()
if err := persistence.SaveSystemSettings(*initial); err != nil {
t.Fatal(err)
}
body, _ := json.Marshal(map[string]interface{}{"telemetryEnabled": false})
req := httptest.NewRequest(http.MethodPost, "/api/system-settings", bytes.NewReader(body))
req.Header.Set("X-API-Token", tokenVal)
rec := httptest.NewRecorder()
handler.HandleUpdateSystemSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
if !saveCalled {
t.Error("expected reload (post-save) to be called")
}
// In-memory should now be false (applied after successful save).
if cfg.TelemetryEnabled {
t.Error("TelemetryEnabled should be false after successful update")
}
}
func TestTelemetryUpdate_GetReturnsEffectiveValue(t *testing.T) {
tempDir := t.TempDir()
cfg := &config.Config{
DataPath: tempDir,
ConfigPath: tempDir,
TelemetryEnabled: true,
EnvOverrides: map[string]bool{"PULSE_TELEMETRY": true},
}
handler, persistence, _ := setupTelemetryTest(t, cfg)
// Persist with telemetry disabled (simulating a stale disk value).
initial := config.DefaultSystemSettings()
disabled := false
initial.TelemetryEnabled = &disabled
if err := persistence.SaveSystemSettings(*initial); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/api/system-settings", nil)
rec := httptest.NewRecorder()
handler.HandleGetSystemSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rec.Code)
}
var response struct {
TelemetryEnabled *bool `json:"telemetryEnabled"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
t.Fatalf("decode: %v", err)
}
if response.TelemetryEnabled == nil {
t.Fatal("telemetryEnabled should be present in response")
}
if !*response.TelemetryEnabled {
t.Error("GET should return effective runtime value (true), not stale disk value (false)")
}
}
func TestTelemetryPreview_ReturnsCurrentPayload(t *testing.T) {
tempDir := t.TempDir()
cfg := &config.Config{
DataPath: tempDir,
ConfigPath: tempDir,
TelemetryEnabled: false,
EnvOverrides: make(map[string]bool),
}
handler, _, _ := setupTelemetryTest(t, cfg)
handler.SetTelemetryPreviewFunc(func() (telemetry.Ping, error) {
return telemetry.Ping{
InstallID: "preview-install-id",
Version: "6.0.0",
Event: "heartbeat",
Platform: "docker",
OS: "linux",
Arch: "amd64",
}, nil
})
req := httptest.NewRequest(http.MethodGet, "/api/system/settings/telemetry-preview", nil)
rec := httptest.NewRecorder()
handler.HandleGetTelemetryPreview(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
var response TelemetryPreviewResponse
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
t.Fatalf("decode: %v", err)
}
if response.Enabled {
t.Fatal("expected telemetry preview to reflect disabled runtime state")
}
if response.Payload.InstallID != "preview-install-id" {
t.Fatalf("install_id = %q, want preview-install-id", response.Payload.InstallID)
}
if response.Payload.Event != "heartbeat" {
t.Fatalf("event = %q, want heartbeat", response.Payload.Event)
}
}
func TestTelemetryReset_ReturnsUpdatedPayload(t *testing.T) {
tempDir := t.TempDir()
cfg := &config.Config{
DataPath: tempDir,
ConfigPath: tempDir,
TelemetryEnabled: true,
EnvOverrides: make(map[string]bool),
}
handler, _, token := setupTelemetryTest(t, cfg)
handler.SetTelemetryResetFunc(func() (telemetry.Ping, error) {
return telemetry.Ping{
InstallID: "rotated-install-id",
Version: "6.0.0",
Event: "heartbeat",
Platform: "binary",
OS: "linux",
Arch: "amd64",
}, nil
})
req := httptest.NewRequest(http.MethodPost, "/api/system/settings/telemetry-reset-id", nil)
req.Header.Set("X-API-Token", token)
rec := httptest.NewRecorder()
handler.HandleResetTelemetryID(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
var response TelemetryPreviewResponse
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
t.Fatalf("decode: %v", err)
}
if !response.Enabled {
t.Fatal("expected telemetry reset response to reflect enabled runtime state")
}
if response.Payload.InstallID != "rotated-install-id" {
t.Fatalf("install_id = %q, want rotated-install-id", response.Payload.InstallID)
}
}
func TestTelemetryUpdate_NoMutationOnPersistFailure(t *testing.T) {
tempDir := t.TempDir()
cfg := &config.Config{
DataPath: tempDir,
ConfigPath: tempDir,
TelemetryEnabled: true,
PVEPollingInterval: 10 * time.Second,
EnvOverrides: make(map[string]bool),
}
handler, persistence, token := setupTelemetryTest(t, cfg)
initial := config.DefaultSystemSettings()
if err := persistence.SaveSystemSettings(*initial); err != nil {
t.Fatal(err)
}
// Make the config directory read-only so SaveSystemSettings fails.
systemFile := filepath.Join(tempDir, "system.json")
if err := os.Chmod(systemFile, 0400); err != nil {
t.Fatal(err)
}
if err := os.Chmod(tempDir, 0500); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_ = os.Chmod(tempDir, 0700) // restore so TempDir cleanup works
_ = os.Chmod(systemFile, 0600) // restore so TempDir cleanup works
})
body, _ := json.Marshal(map[string]interface{}{"telemetryEnabled": false})
req := httptest.NewRequest(http.MethodPost, "/api/system-settings", bytes.NewReader(body))
req.Header.Set("X-API-Token", token)
rec := httptest.NewRecorder()
handler.HandleUpdateSystemSettings(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected 500 when persistence fails, got %d: %s", rec.Code, rec.Body.String())
}
// In-memory config must NOT have been mutated.
if !cfg.TelemetryEnabled {
t.Error("TelemetryEnabled should still be true after persistence failure")
}
}
func TestTelemetryUpdate_UnrelatedUpdateDoesNotToggle(t *testing.T) {
tempDir := t.TempDir()
cfg := &config.Config{
DataPath: tempDir,
ConfigPath: tempDir,
TelemetryEnabled: true,
EnvOverrides: make(map[string]bool),
}
handler, persistence, token := setupTelemetryTest(t, cfg)
// Persist with telemetry enabled.
initial := config.DefaultSystemSettings()
enabled := true
initial.TelemetryEnabled = &enabled
if err := persistence.SaveSystemSettings(*initial); err != nil {
t.Fatal(err)
}
// Track whether toggle callback fires.
toggleCalled := false
handler.SetTelemetryToggleFunc(func(en bool) {
toggleCalled = true
})
// Send an update that does NOT include telemetryEnabled.
body, _ := json.Marshal(map[string]interface{}{"theme": "dark"})
req := httptest.NewRequest(http.MethodPost, "/api/system-settings", bytes.NewReader(body))
req.Header.Set("X-API-Token", token)
rec := httptest.NewRecorder()
handler.HandleUpdateSystemSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
if toggleCalled {
t.Error("telemetry toggle callback should NOT fire for unrelated settings updates")
}
}
func TestCIDRUpdate_InvalidCIDRRejectedBeforePersist(t *testing.T) {
tempDir := t.TempDir()
cfg := &config.Config{
DataPath: tempDir,
ConfigPath: tempDir,
TelemetryEnabled: true,
EnvOverrides: make(map[string]bool),
}
handler, persistence, token := setupTelemetryTest(t, cfg)
initial := config.DefaultSystemSettings()
initial.WebhookAllowedPrivateCIDRs = "192.168.1.0/24"
if err := persistence.SaveSystemSettings(*initial); err != nil {
t.Fatal(err)
}
// Send an invalid CIDR value — should be rejected with 400 before persisting.
body, _ := json.Marshal(map[string]interface{}{"webhookAllowedPrivateCIDRs": "not-a-cidr"})
req := httptest.NewRequest(http.MethodPost, "/api/system-settings", bytes.NewReader(body))
req.Header.Set("X-API-Token", token)
rec := httptest.NewRecorder()
handler.HandleUpdateSystemSettings(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for invalid CIDR, got %d: %s", rec.Code, rec.Body.String())
}
// Verify persisted value was NOT changed.
saved, err := persistence.LoadSystemSettings()
if err != nil {
t.Fatal(err)
}
if saved.WebhookAllowedPrivateCIDRs != "192.168.1.0/24" {
t.Errorf("persisted CIDRs should be unchanged, got %q", saved.WebhookAllowedPrivateCIDRs)
}
}
func TestCIDRUpdate_ValidCIDRPersisted(t *testing.T) {
tempDir := t.TempDir()
cfg := &config.Config{
DataPath: tempDir,
ConfigPath: tempDir,
TelemetryEnabled: true,
EnvOverrides: make(map[string]bool),
}
handler, persistence, token := setupTelemetryTest(t, cfg)
initial := config.DefaultSystemSettings()
if err := persistence.SaveSystemSettings(*initial); err != nil {
t.Fatal(err)
}
// Send a valid CIDR value.
body, _ := json.Marshal(map[string]interface{}{"webhookAllowedPrivateCIDRs": "10.0.0.0/8, 172.16.0.0/12"})
req := httptest.NewRequest(http.MethodPost, "/api/system-settings", bytes.NewReader(body))
req.Header.Set("X-API-Token", token)
rec := httptest.NewRecorder()
handler.HandleUpdateSystemSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
// Verify persisted value was updated.
saved, err := persistence.LoadSystemSettings()
if err != nil {
t.Fatal(err)
}
if saved.WebhookAllowedPrivateCIDRs != "10.0.0.0/8, 172.16.0.0/12" {
t.Errorf("persisted CIDRs should be updated, got %q", saved.WebhookAllowedPrivateCIDRs)
}
}
// The autoUpdateCheckInterval and autoUpdateTime settings were removed as dead
// surface: nothing ever consumed them (the systemd timer schedule is rendered
// by install.sh) and no UI control set them, so accepting and persisting them
// only pretended a schedule preference existed (#1643/#1637 triage). Legacy
// clients may still send the fields; the update endpoint must ignore them
// without erroring — including values the retired validation used to reject —
// and must not write them back into system.json.
func TestSystemSettingsUpdate_LegacyAutoUpdateFieldsIgnored(t *testing.T) {
tempDir := t.TempDir()
cfg := &config.Config{
DataPath: tempDir,
ConfigPath: tempDir,
EnvOverrides: map[string]bool{},
}
handler, persistence, token := setupTelemetryTest(t, cfg)
initial := config.DefaultSystemSettings()
if err := persistence.SaveSystemSettings(*initial); err != nil {
t.Fatal(err)
}
body, _ := json.Marshal(map[string]interface{}{
"autoUpdateCheckInterval": float64(9999), // rejected by the retired validation
"autoUpdateTime": "99:99",
"connectionTimeout": float64(30),
})
req := httptest.NewRequest(http.MethodPost, "/api/system-settings", bytes.NewReader(body))
req.Header.Set("X-API-Token", token)
rec := httptest.NewRecorder()
handler.HandleUpdateSystemSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("legacy auto-update fields must be ignored, got %d: %s", rec.Code, rec.Body.String())
}
raw, err := os.ReadFile(filepath.Join(tempDir, "system.json"))
if err != nil {
t.Fatalf("read persisted system.json: %v", err)
}
var persisted map[string]interface{}
if err := json.Unmarshal(raw, &persisted); err != nil {
t.Fatalf("parse persisted system.json: %v", err)
}
for _, key := range []string{"autoUpdateCheckInterval", "autoUpdateTime"} {
if _, ok := persisted[key]; ok {
t.Fatalf("persisted system.json still carries dead setting %q:\n%s", key, raw)
}
}
saved, err := persistence.LoadSystemSettings()
if err != nil {
t.Fatalf("load persisted settings: %v", err)
}
if saved.ConnectionTimeout != 30 {
t.Fatalf("real setting alongside legacy fields was dropped: connectionTimeout=%d", saved.ConnectionTimeout)
}
}
// TestIssue1638SettingsSaveResetsSSHFailureBackoff pins that saving system
// settings clears the temperature SSH failure backoff on every live monitor.
// A settings save is an operator touchpoint that often follows repairing SSH
// access, and the on-disk key-change check only notices key file replacement,
// so without this the operator waits out a backoff window that may have
// compounded to fifteen minutes before Pulse retries (#1638).
func TestIssue1638SettingsSaveResetsSSHFailureBackoff(t *testing.T) {
tempDir := t.TempDir()
cfg := &config.Config{
DataPath: tempDir,
ConfigPath: tempDir,
EnvOverrides: make(map[string]bool),
}
persistence := config.NewConfigPersistence(cfg.DataPath)
tokenVal := "ssh-reset-test-token-123.12345678"
tokenHash := internalauth.HashAPIToken(tokenVal)
cfg.APITokens = []config.APITokenRecord{
{ID: "tok1", Hash: tokenHash, Name: "Test Token"},
}
monitor := &mockMonitor{}
handler := newTestSystemSettingsHandler(cfg, persistence, monitor, func() {}, func() error { return nil })
initial := config.DefaultSystemSettings()
if err := persistence.SaveSystemSettings(*initial); err != nil {
t.Fatal(err)
}
// A save that touches no SSH-related field must still clear the backoff:
// the reset is tied to the save itself, not to any particular setting.
body, _ := json.Marshal(map[string]interface{}{"connectionTimeout": 30})
req := httptest.NewRequest(http.MethodPost, "/api/system-settings", bytes.NewReader(body))
req.Header.Set("X-API-Token", tokenVal)
rec := httptest.NewRecorder()
handler.HandleUpdateSystemSettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
if monitor.resetSSHFailureBackoffCalls != 1 {
t.Fatalf("settings save called ResetSSHFailureBackoff %d times, want 1", monitor.resetSSHFailureBackoffCalls)
}
}