mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-25 13:10:03 +00:00
33345f2981
Adapt PR2351 to current main without losing bounded probe deadlines, retries, explicit binds or confidentiality. Inspect IPv6 wildcard socket mode so unrelated servers sharing the port cannot determine Pulse health. Apply the telemetry callback only after a persisted explicit boolean transition, including stale-disk and null-input boundaries.
Retain real HTTP/HTTPS wildcard and same-port isolation regressions, callback persistence-order tests, and the owning subsystem contracts. No dependency manifests, frontend source or telemetry payload schema change. Exact runtime proof remains dependent on the unavailable root graph; it is not represented as passed.
Change-source: pulse-maintainer
Original-source: https://github.com/rcourtman/Pulse/pull/2351
Original-commit: f50c4d6e3b
Co-authored-by: rcourtman <8825017+rcourtman@users.noreply.github.com>
751 lines
25 KiB
Go
751 lines
25 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"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 TestSecurityOperatorDocsPublishEffectiveConfigTransferPolicy(t *testing.T) {
|
|
rootSecurity, err := os.ReadFile(filepath.Clean("../../SECURITY.md"))
|
|
if err != nil {
|
|
t.Fatalf("read root security guide: %v", err)
|
|
}
|
|
shippedSecurity, err := os.ReadFile(filepath.Clean("../../frontend-modern/public/docs/SECURITY.md"))
|
|
if err != nil {
|
|
t.Fatalf("read shipped security guide: %v", err)
|
|
}
|
|
if !bytes.Equal(rootSecurity, shippedSecurity) {
|
|
t.Fatal("shipped security guide drifted from the canonical root guide")
|
|
}
|
|
guide := strings.Join(strings.Fields(string(rootSecurity)), " ")
|
|
for _, policy := range []string{
|
|
"only to configuration export",
|
|
"It never enables import",
|
|
"direct loopback connection",
|
|
"private-network and forwarded requests are not loopback",
|
|
"settings:read",
|
|
"settings:write",
|
|
"organization-bound tokens",
|
|
} {
|
|
if !strings.Contains(guide, policy) {
|
|
t.Fatalf("security guide missing config transfer policy %q", policy)
|
|
}
|
|
}
|
|
}
|
|
|
|
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 TestRuntimeDisplayPublishesOnlyEffectiveTelemetryState(t *testing.T) {
|
|
settings := config.DefaultSystemSettings()
|
|
persistedEnabled := true
|
|
settings.TelemetryEnabled = &persistedEnabled
|
|
handler := newRuntimeDisplayHandler(t, &config.Config{
|
|
TelemetryEnabled: false,
|
|
EnvOverrides: map[string]bool{
|
|
"PULSE_TELEMETRY": true,
|
|
},
|
|
}, settings)
|
|
|
|
display, raw := fetchRuntimeDisplay(t, handler)
|
|
if display.TelemetryEnabled {
|
|
t.Fatal("runtime telemetry state = enabled, want effective disabled override")
|
|
}
|
|
if _, ok := raw["telemetryEnabled"]; !ok {
|
|
t.Fatal("runtime display omitted telemetryEnabled")
|
|
}
|
|
if _, ok := raw["telemetryPreview"]; ok {
|
|
t.Fatal("runtime display exposed admin-only telemetry preview")
|
|
}
|
|
}
|
|
|
|
// The monitoring cadence rides the same session-tier projection as the
|
|
// telemetry state. Publishing it must not drag the admin-only settings
|
|
// payload's neighbours along with it.
|
|
func TestRuntimeDisplayCadenceProjectionStaysNarrow(t *testing.T) {
|
|
settings := config.DefaultSystemSettings()
|
|
settings.PVEPollingInterval = 45
|
|
handler := newRuntimeDisplayHandler(t, &config.Config{}, settings)
|
|
|
|
display, raw := fetchRuntimeDisplay(t, handler)
|
|
if display.PVEPollingInterval != 45 {
|
|
t.Fatalf("pvePollingInterval = %d, want 45", display.PVEPollingInterval)
|
|
}
|
|
if _, ok := raw["pvePollingInterval"]; !ok {
|
|
t.Fatal("runtime display omitted pvePollingInterval")
|
|
}
|
|
for _, adminKey := range []string{"envOverrides", "discoveryConfig", "allowedOrigins", "backupPollingInterval", "publicURL"} {
|
|
if _, ok := raw[adminKey]; ok {
|
|
t.Fatalf("runtime display exposed admin-only %q", adminKey)
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
toggleCalled := false
|
|
handler.SetTelemetryToggleFunc(func(bool) { toggleCalled = true })
|
|
|
|
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")
|
|
}
|
|
if toggleCalled {
|
|
t.Fatal("failed persistence invoked the telemetry toggle")
|
|
}
|
|
}
|
|
|
|
func TestTelemetryUpdate_OnlyPreferenceTransitionsToggle(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)
|
|
settings := config.DefaultSystemSettings()
|
|
enabled := true
|
|
settings.TelemetryEnabled = &enabled
|
|
if err := persistence.SaveSystemSettings(*settings); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var toggles []bool
|
|
handler.SetTelemetryToggleFunc(func(value bool) {
|
|
// The sender must observe the committed preference, not a proposed
|
|
// value that could still fail persistence.
|
|
persisted, err := persistence.LoadSystemSettings()
|
|
if err != nil || persisted.TelemetryEnabled == nil || *persisted.TelemetryEnabled != value {
|
|
t.Fatalf("toggle preceded persistence: settings=%#v error=%v", persisted, err)
|
|
}
|
|
if cfg.TelemetryEnabled != value {
|
|
t.Fatalf("toggle preceded runtime mutation: enabled=%v want=%v", cfg.TelemetryEnabled, value)
|
|
}
|
|
toggles = append(toggles, value)
|
|
})
|
|
for i, value := range []bool{true, true, false, false, true, true} {
|
|
body, err := json.Marshal(map[string]interface{}{"telemetryEnabled": value})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
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 || cfg.TelemetryEnabled != value {
|
|
t.Fatalf("save %d returned %d, enabled=%v: %s", i, rec.Code, cfg.TelemetryEnabled, rec.Body.String())
|
|
}
|
|
persisted, err := persistence.LoadSystemSettings()
|
|
if err != nil || persisted.TelemetryEnabled == nil || *persisted.TelemetryEnabled != value {
|
|
t.Fatalf("save %d did not preserve requested preference: settings=%#v error=%v", i, persisted, err)
|
|
}
|
|
wantToggleCount := 0
|
|
if i >= 2 {
|
|
wantToggleCount++
|
|
}
|
|
if i >= 4 {
|
|
wantToggleCount++
|
|
}
|
|
if len(toggles) != wantToggleCount {
|
|
t.Fatalf("save %d toggles = %v, want %d transitions", i, toggles, wantToggleCount)
|
|
}
|
|
}
|
|
if len(toggles) != 2 || toggles[0] != false || toggles[1] != true {
|
|
t.Fatalf("runtime toggles = %v, want only the disable and re-enable transitions", toggles)
|
|
}
|
|
}
|
|
|
|
func TestTelemetryUpdate_UnchangedPreferenceRepairsStaleDiskWithoutToggle(t *testing.T) {
|
|
for _, enabled := range []bool{true, false} {
|
|
t.Run(fmt.Sprintf("enabled=%v", enabled), func(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
cfg := &config.Config{
|
|
DataPath: tempDir, ConfigPath: tempDir, TelemetryEnabled: enabled,
|
|
EnvOverrides: make(map[string]bool),
|
|
}
|
|
handler, persistence, token := setupTelemetryTest(t, cfg)
|
|
settings := config.DefaultSystemSettings()
|
|
stale := !enabled
|
|
settings.TelemetryEnabled = &stale
|
|
if err := persistence.SaveSystemSettings(*settings); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
handler.SetTelemetryToggleFunc(func(bool) { t.Fatal("unchanged effective preference invoked toggle") })
|
|
body, err := json.Marshal(map[string]interface{}{"telemetryEnabled": enabled})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
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 || cfg.TelemetryEnabled != enabled {
|
|
t.Fatalf("unchanged save returned %d, enabled=%v: %s", rec.Code, cfg.TelemetryEnabled, rec.Body.String())
|
|
}
|
|
persisted, err := persistence.LoadSystemSettings()
|
|
if err != nil || persisted.TelemetryEnabled == nil || *persisted.TelemetryEnabled != enabled {
|
|
t.Fatalf("unchanged save did not repair disk: settings=%#v error=%v", persisted, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTelemetryUpdate_NullPreservesEffectiveValueWithStalePreference(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)
|
|
settings := config.DefaultSystemSettings()
|
|
stale := false
|
|
settings.TelemetryEnabled = &stale
|
|
if err := persistence.SaveSystemSettings(*settings); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
handler.SetTelemetryToggleFunc(func(bool) { t.Fatal("null preference invoked toggle") })
|
|
req := httptest.NewRequest(http.MethodPost, "/api/system-settings", strings.NewReader(`{"telemetryEnabled":null}`))
|
|
req.Header.Set("X-API-Token", token)
|
|
rec := httptest.NewRecorder()
|
|
handler.HandleUpdateSystemSettings(rec, req)
|
|
if rec.Code != http.StatusOK || !cfg.TelemetryEnabled {
|
|
t.Fatalf("null preference changed runtime: status=%d enabled=%v", rec.Code, cfg.TelemetryEnabled)
|
|
}
|
|
persisted, err := persistence.LoadSystemSettings()
|
|
if err != nil || persisted.TelemetryEnabled == nil || *persisted.TelemetryEnabled {
|
|
t.Fatalf("null preference changed disk: settings=%#v error=%v", persisted, err)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|