Pulse/internal/api/system_settings_handlers_test.go
2026-03-18 16:06:30 +00:00

541 lines
16 KiB
Go

package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"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/discovery"
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
"github.com/rcourtman/pulse-go-rewrite/internal/notifications"
"github.com/rcourtman/pulse-go-rewrite/internal/websocket"
internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
)
// MockMonitor implementation
type mockMonitor struct {
}
func (m *mockMonitor) GetDiscoveryService() *discovery.Service { return nil }
func (m *mockMonitor) StartDiscoveryService(ctx context.Context, wsHub *websocket.Hub, subnet string) {
}
func (m *mockMonitor) StopDiscoveryService() {}
func (m *mockMonitor) EnableTemperatureMonitoring() {}
func (m *mockMonitor) DisableTemperatureMonitoring() {}
func (m *mockMonitor) GetNotificationManager() *notifications.NotificationManager { return nil }
type mockTenantMonitorProvider struct {
orgID string
monitor *monitoring.Monitor
err error
}
func (m *mockTenantMonitorProvider) GetMonitor(orgID string) (*monitoring.Monitor, error) {
m.orgID = orgID
if m.err != nil {
return nil, m.err
}
return m.monitor, nil
}
func newTestSystemSettingsHandler(cfg *config.Config, persistence *config.ConfigPersistence, monitor SystemSettingsMonitor, reloadSystemSettingsFunc func(), reloadMonitorFunc func() error) *SystemSettingsHandler {
handler := NewSystemSettingsHandler(cfg, persistence, nil, nil, monitor, reloadSystemSettingsFunc, reloadMonitorFunc)
handler.mtMonitor = nil
return handler
}
func TestHandleGetSystemSettings(t *testing.T) {
tempDir := t.TempDir()
cfg := &config.Config{
DataPath: tempDir,
ConfigPath: tempDir,
PVEPollingInterval: 30 * time.Second,
BackupPollingInterval: 1 * time.Hour,
EnableBackupPolling: true,
TemperatureMonitoringEnabled: true,
}
persistence := config.NewConfigPersistence(tempDir)
monitor := &mockMonitor{}
handler := newTestSystemSettingsHandler(cfg, persistence, monitor, func() {}, func() error { return nil })
// Save some settings first
initialSettings := config.DefaultSystemSettings()
initialSettings.Theme = "dark"
if err := persistence.SaveSystemSettings(*initialSettings); err != nil {
t.Fatalf("Failed to save initial settings: %v", 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 status 200, got %d", rec.Code)
}
var response SystemSettingsResponse
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if response.Theme != "dark" {
t.Errorf("Expected theme 'dark', got '%s'", response.Theme)
}
if response.EnvOverrides == nil {
t.Fatalf("expected envOverrides to normalize to an empty map")
}
}
func TestHandleGetSystemSettings_DisablesAutoUpdatesOnRC(t *testing.T) {
tempDir := t.TempDir()
cfg := &config.Config{
DataPath: tempDir,
ConfigPath: tempDir,
UpdateChannel: "rc",
AutoUpdateEnabled: true,
}
persistence := config.NewConfigPersistence(tempDir)
monitor := &mockMonitor{}
handler := newTestSystemSettingsHandler(cfg, persistence, monitor, func() {}, func() error { return nil })
initialSettings := config.DefaultSystemSettings()
initialSettings.UpdateChannel = "rc"
initialSettings.AutoUpdateEnabled = true
if err := persistence.SaveSystemSettings(*initialSettings); err != nil {
t.Fatalf("Failed to save initial settings: %v", 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 status 200, got %d", rec.Code)
}
var response struct {
UpdateChannel string `json:"updateChannel"`
AutoUpdateEnabled bool `json:"autoUpdateEnabled"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if response.UpdateChannel != "rc" {
t.Fatalf("expected updateChannel rc, got %q", response.UpdateChannel)
}
if response.AutoUpdateEnabled {
t.Fatalf("expected autoUpdateEnabled to be normalized off on rc")
}
}
func TestHandleGetSystemSettings_UsesRuntimeRCWhenPersistedChannelMissing(t *testing.T) {
tempDir := t.TempDir()
cfg := &config.Config{
DataPath: tempDir,
ConfigPath: tempDir,
UpdateChannel: "rc",
AutoUpdateEnabled: true,
}
persistence := config.NewConfigPersistence(tempDir)
monitor := &mockMonitor{}
handler := newTestSystemSettingsHandler(cfg, persistence, monitor, func() {}, func() error { return nil })
initialSettings := config.DefaultSystemSettings()
initialSettings.AutoUpdateEnabled = true
if err := persistence.SaveSystemSettings(*initialSettings); err != nil {
t.Fatalf("Failed to save initial settings: %v", 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 status 200, got %d", rec.Code)
}
var response struct {
UpdateChannel string `json:"updateChannel"`
AutoUpdateEnabled bool `json:"autoUpdateEnabled"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
t.Fatalf("Failed to decode response: %v", err)
}
if response.UpdateChannel != "rc" {
t.Fatalf("expected runtime updateChannel rc, got %q", response.UpdateChannel)
}
if response.AutoUpdateEnabled {
t.Fatalf("expected autoUpdateEnabled to be normalized off on runtime rc")
}
}
func TestHandleGetSystemSettings_LoadError(t *testing.T) {
tempDir := t.TempDir()
cfg := &config.Config{DataPath: tempDir}
persistence := config.NewConfigPersistence(tempDir)
handler := newTestSystemSettingsHandler(cfg, persistence, &mockMonitor{}, func() {}, func() error { return nil })
// Write invalid JSON
systemFile := filepath.Join(tempDir, "system.json")
if err := os.MkdirAll(tempDir, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(systemFile, []byte("{invalid json"), 0644); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/api/system-settings", nil)
rec := httptest.NewRecorder()
handler.HandleGetSystemSettings(rec, req)
// Should fallback to defaults
if rec.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", rec.Code)
}
}
func TestHandleUpdateSystemSettings_Basic(t *testing.T) {
tempDir := t.TempDir()
cfg := &config.Config{
DataPath: tempDir,
ConfigPath: tempDir,
}
persistence := config.NewConfigPersistence(tempDir)
monitor := &mockMonitor{}
handler := newTestSystemSettingsHandler(cfg, persistence, monitor, func() {}, func() error { return nil })
// Setup Authentication (API Token)
tokenVal := "testtoken123"
tokenHash := internalauth.HashAPIToken(tokenVal)
cfg.APITokens = []config.APITokenRecord{
{
ID: "token1",
Hash: tokenHash,
Name: "Test Token",
},
}
updates := map[string]interface{}{
"theme": "light",
"pvePollingInterval": 60,
}
body, _ := json.Marshal(updates)
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 status 200, got %d, body: %s", rec.Code, rec.Body.String())
}
// Verify persistence
loaded, err := persistence.LoadSystemSettings()
if err != nil {
t.Fatalf("Failed to load settings: %v", err)
}
if loaded.Theme != "light" {
t.Errorf("Expected theme 'light', got '%s'", loaded.Theme)
}
if loaded.PVEPollingInterval != 60 {
t.Errorf("Expected PVEPollingInterval 60, got %d", loaded.PVEPollingInterval)
}
// Verify config update
if cfg.PVEPollingInterval != 60*time.Second {
t.Errorf("Config was not updated. Expected 60s, got %v", cfg.PVEPollingInterval)
}
}
func TestHandleUpdateSystemSettings_DisablesAutoUpdatesWhenRCSelected(t *testing.T) {
tempDir := t.TempDir()
cfg := &config.Config{
DataPath: tempDir,
ConfigPath: tempDir,
}
persistence := config.NewConfigPersistence(tempDir)
monitor := &mockMonitor{}
handler := newTestSystemSettingsHandler(cfg, persistence, monitor, func() {}, func() error { return nil })
tokenVal := "testtoken123"
tokenHash := internalauth.HashAPIToken(tokenVal)
cfg.APITokens = []config.APITokenRecord{
{
ID: "token1",
Hash: tokenHash,
Name: "Test Token",
},
}
updates := map[string]interface{}{
"updateChannel": "rc",
"autoUpdateEnabled": true,
}
body, _ := json.Marshal(updates)
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 status 200, got %d, body: %s", rec.Code, rec.Body.String())
}
loaded, err := persistence.LoadSystemSettings()
if err != nil {
t.Fatalf("Failed to load settings: %v", err)
}
if loaded.UpdateChannel != "rc" {
t.Fatalf("expected saved updateChannel rc, got %q", loaded.UpdateChannel)
}
if loaded.AutoUpdateEnabled {
t.Fatalf("expected saved autoUpdateEnabled to be false on rc")
}
if cfg.AutoUpdateEnabled {
t.Fatalf("expected runtime autoUpdateEnabled to be false on rc")
}
}
func TestHandleUpdateSystemSettings_UsesRuntimeRCWhenChannelOmitted(t *testing.T) {
tempDir := t.TempDir()
cfg := &config.Config{
DataPath: tempDir,
ConfigPath: tempDir,
UpdateChannel: "rc",
}
persistence := config.NewConfigPersistence(tempDir)
monitor := &mockMonitor{}
handler := newTestSystemSettingsHandler(cfg, persistence, monitor, func() {}, func() error { return nil })
tokenVal := "testtoken123"
tokenHash := internalauth.HashAPIToken(tokenVal)
cfg.APITokens = []config.APITokenRecord{
{
ID: "token1",
Hash: tokenHash,
Name: "Test Token",
},
}
initialSettings := config.DefaultSystemSettings()
if err := persistence.SaveSystemSettings(*initialSettings); err != nil {
t.Fatalf("Failed to save initial settings: %v", err)
}
updates := map[string]interface{}{
"autoUpdateEnabled": true,
}
body, _ := json.Marshal(updates)
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 status 200, got %d, body: %s", rec.Code, rec.Body.String())
}
loaded, err := persistence.LoadSystemSettings()
if err != nil {
t.Fatalf("Failed to load settings: %v", err)
}
if loaded.UpdateChannel != "rc" {
t.Fatalf("expected saved updateChannel rc from runtime fallback, got %q", loaded.UpdateChannel)
}
if loaded.AutoUpdateEnabled {
t.Fatalf("expected saved autoUpdateEnabled to remain false on runtime rc")
}
if cfg.AutoUpdateEnabled {
t.Fatalf("expected runtime autoUpdateEnabled to remain false on runtime rc")
}
}
func TestHandleUpdateSystemSettings_DiscoveryConfigAppliedFromCamelCasePayload(t *testing.T) {
tempDir := t.TempDir()
cfg := &config.Config{
DataPath: tempDir,
ConfigPath: tempDir,
Discovery: config.DefaultDiscoveryConfig(),
}
persistence := config.NewConfigPersistence(tempDir)
monitor := &mockMonitor{}
handler := newTestSystemSettingsHandler(cfg, persistence, monitor, func() {}, func() error { return nil })
tokenVal := "testtoken123"
tokenHash := internalauth.HashAPIToken(tokenVal)
cfg.APITokens = []config.APITokenRecord{
{ID: "token1", Hash: tokenHash, Name: "Test Token"},
}
updates := map[string]interface{}{
"discoveryConfig": map[string]interface{}{
"environmentOverride": "docker-bridge",
"subnetAllowlist": []string{"10.0.0.0/8"},
"subnetBlocklist": []string{"169.254.0.0/16"},
"maxHostsPerScan": 77,
"maxConcurrent": 11,
"enableReverseDns": false,
"scanGateways": false,
"dialTimeoutMs": 1500,
"httpTimeoutMs": 2300,
},
}
body, _ := json.Marshal(updates)
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 status 200, got %d, body: %s", rec.Code, rec.Body.String())
}
loaded, err := persistence.LoadSystemSettings()
if err != nil {
t.Fatalf("Failed to load settings: %v", err)
}
got := loaded.DiscoveryConfig
if got.EnvironmentOverride != "docker-bridge" {
t.Fatalf("EnvironmentOverride = %q, want %q", got.EnvironmentOverride, "docker-bridge")
}
if got.MaxHostsPerScan != 77 {
t.Fatalf("MaxHostsPerScan = %d, want 77", got.MaxHostsPerScan)
}
if got.MaxConcurrent != 11 {
t.Fatalf("MaxConcurrent = %d, want 11", got.MaxConcurrent)
}
if got.EnableReverseDNS {
t.Fatalf("EnableReverseDNS = true, want false")
}
if got.ScanGateways {
t.Fatalf("ScanGateways = true, want false")
}
if got.DialTimeout != 1500 {
t.Fatalf("DialTimeout = %d, want 1500", got.DialTimeout)
}
if got.HTTPTimeout != 2300 {
t.Fatalf("HTTPTimeout = %d, want 2300", got.HTTPTimeout)
}
if len(got.SubnetAllowlist) != 1 || got.SubnetAllowlist[0] != "10.0.0.0/8" {
t.Fatalf("SubnetAllowlist = %v, want [10.0.0.0/8]", got.SubnetAllowlist)
}
if len(got.SubnetBlocklist) != 1 || got.SubnetBlocklist[0] != "169.254.0.0/16" {
t.Fatalf("SubnetBlocklist = %v, want [169.254.0.0/16]", got.SubnetBlocklist)
}
if cfg.Discovery.EnvironmentOverride != "docker-bridge" {
t.Fatalf("runtime config EnvironmentOverride = %q, want %q", cfg.Discovery.EnvironmentOverride, "docker-bridge")
}
}
func TestHandleUpdateSystemSettings_Unauthorized(t *testing.T) {
tempDir := t.TempDir()
cfg := &config.Config{
DataPath: tempDir,
AuthUser: "admin",
AuthPass: "password", // Requires auth
}
persistence := config.NewConfigPersistence(tempDir)
handler := newTestSystemSettingsHandler(cfg, persistence, &mockMonitor{}, func() {}, func() error { return nil })
req := httptest.NewRequest(http.MethodPost, "/api/system-settings", nil)
rec := httptest.NewRecorder()
handler.HandleUpdateSystemSettings(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected status 401, got %d", rec.Code)
}
}
func TestHandleUpdateSystemSettings_Validation(t *testing.T) {
tempDir := t.TempDir()
cfg := &config.Config{
DataPath: tempDir,
ConfigPath: tempDir,
}
persistence := config.NewConfigPersistence(tempDir)
handler := newTestSystemSettingsHandler(cfg, persistence, &mockMonitor{}, func() {}, func() error { return nil })
// Setup Auth
tokenVal := "testtoken123"
tokenHash := internalauth.HashAPIToken(tokenVal)
cfg.APITokens = []config.APITokenRecord{
{ID: "token1", Hash: tokenHash},
}
updates := map[string]interface{}{
"pvePollingInterval": -1, // Invalid
}
body, _ := json.Marshal(updates)
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.StatusBadRequest {
t.Fatalf("expected status 400, got %d", rec.Code)
}
}
func TestSystemSettingsHandler_GetMonitor_UsesTenantMonitorInterface(t *testing.T) {
tenantMonitor := &monitoring.Monitor{}
defaultMonitor := &mockMonitor{}
provider := &mockTenantMonitorProvider{monitor: tenantMonitor}
handler := &SystemSettingsHandler{
mtMonitor: provider,
defaultMonitor: defaultMonitor,
}
got := handler.getMonitor(context.Background())
if got != tenantMonitor {
t.Fatalf("expected tenant monitor, got %#v", got)
}
if provider.orgID != "default" {
t.Fatalf("expected org lookup for default context org, got %q", provider.orgID)
}
}
func TestSystemSettingsHandler_GetMonitor_FallsBackToLegacyOnTenantError(t *testing.T) {
defaultMonitor := &mockMonitor{}
provider := &mockTenantMonitorProvider{err: errors.New("boom")}
handler := &SystemSettingsHandler{
mtMonitor: provider,
defaultMonitor: defaultMonitor,
}
got := handler.getMonitor(context.WithValue(context.Background(), OrgIDContextKey, "acme"))
if got != defaultMonitor {
t.Fatalf("expected legacy monitor fallback, got %#v", got)
}
if provider.orgID != "acme" {
t.Fatalf("expected tenant org lookup for acme, got %q", provider.orgID)
}
}