mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-07 00:37:36 +00:00
541 lines
16 KiB
Go
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)
|
|
}
|
|
}
|