Files
pulse/internal/api/system_settings_telemetry_test.go
T
rcourtman 34194e57be Show the real monitoring cadence to non-admin sessions
Non-admin sessions cannot read GET /api/system/settings, so the Settings
General Monitoring Cadence card fell back to the Realtime (10s) preset
regardless of the configured interval; an issue #1601 reporter read that
as the server polling faster for non-admins. Publish the effective
pvePollingInterval on the authenticated runtime-display projection
(runtime config first, persisted value only as fallback, matching the
admin route's precedence), consume it in the viewer fallback of the
settings state, and run that initialization for sessions without
infrastructureRead too, whose ungated General panel previously never
initialized presentation state at all.
2026-08-12 09:20:01 +01:00

625 lines
20 KiB
Go

package api
import (
"bytes"
"encoding/json"
"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)
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)
}
}