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