Pulse/internal/api/system_settings_telemetry_test.go

445 lines
13 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)
os.Chmod(systemFile, 0600)
})
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)
}
}