From 941e9ed0987705397eb69796163c269681928b81 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 17 Dec 2025 12:47:36 +0000 Subject: [PATCH] test: add unit tests for AI, Kubernetes agent, and clients --- internal/ai/alert_adapter.go | 9 +- internal/ai/alert_adapter_test.go | 109 +++++++ internal/ai/alert_provider_test.go | 167 +++++++++++ internal/ai/alert_threshold_adapter_test.go | 55 ++++ internal/ai/baseline_adapter_test.go | 54 ++++ internal/ai/providers/ollama_test.go | 143 +++++++++ internal/api/ai_handlers_test.go | 302 ++++++++++++++++++++ internal/kubernetesagent/agent.go | 2 +- internal/kubernetesagent/agent_test.go | 276 ++++++++++++++++++ pkg/pbs/client_http_test.go | 183 ++++++++++++ pkg/proxmox/zfs_details_test.go | 97 +++++++ pkg/tlsutil/dnscache_test.go | 61 ++++ 12 files changed, 1455 insertions(+), 3 deletions(-) create mode 100644 internal/ai/alert_adapter_test.go create mode 100644 internal/ai/alert_provider_test.go create mode 100644 internal/ai/alert_threshold_adapter_test.go create mode 100644 internal/ai/baseline_adapter_test.go create mode 100644 internal/api/ai_handlers_test.go create mode 100644 internal/kubernetesagent/agent_test.go create mode 100644 pkg/pbs/client_http_test.go create mode 100644 pkg/proxmox/zfs_details_test.go create mode 100644 pkg/tlsutil/dnscache_test.go diff --git a/internal/ai/alert_adapter.go b/internal/ai/alert_adapter.go index b1c8a39..6e53556 100644 --- a/internal/ai/alert_adapter.go +++ b/internal/ai/alert_adapter.go @@ -10,11 +10,16 @@ import ( // AlertManagerAdapter adapts the alerts.Manager to the AI's AlertProvider interface type AlertManagerAdapter struct { - manager *alerts.Manager + manager alertManager +} + +type alertManager interface { + GetActiveAlerts() []alerts.Alert + GetRecentlyResolved() []models.ResolvedAlert } // NewAlertManagerAdapter creates a new adapter for the alert manager -func NewAlertManagerAdapter(manager *alerts.Manager) *AlertManagerAdapter { +func NewAlertManagerAdapter(manager alertManager) *AlertManagerAdapter { return &AlertManagerAdapter{manager: manager} } diff --git a/internal/ai/alert_adapter_test.go b/internal/ai/alert_adapter_test.go new file mode 100644 index 0000000..01afc11 --- /dev/null +++ b/internal/ai/alert_adapter_test.go @@ -0,0 +1,109 @@ +package ai + +import ( + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/alerts" + "github.com/rcourtman/pulse-go-rewrite/internal/models" +) + +type stubAlertManager struct { + active []alerts.Alert + resolved []models.ResolvedAlert +} + +func (s *stubAlertManager) GetActiveAlerts() []alerts.Alert { return s.active } +func (s *stubAlertManager) GetRecentlyResolved() []models.ResolvedAlert { + return s.resolved +} + +func TestAlertManagerAdapter_NilManager(t *testing.T) { + a := NewAlertManagerAdapter(nil) + if got := a.GetActiveAlerts(); got != nil { + t.Fatalf("GetActiveAlerts = %+v, want nil", got) + } + if got := a.GetRecentlyResolved(30); got != nil { + t.Fatalf("GetRecentlyResolved = %+v, want nil", got) + } +} + +func TestAlertManagerAdapter_ConvertsAndFilters(t *testing.T) { + now := time.Now() + active := []alerts.Alert{ + { + ID: "a1", + Type: "node_cpu", + Level: alerts.AlertLevelCritical, + ResourceID: "node:pve1", + ResourceName: "pve1", + Value: 95, + Threshold: 80, + StartTime: now.Add(-2*time.Minute - 10*time.Second), + Metadata: map[string]any{"resourceType": "node"}, + }, + { + ID: "a2", + Type: "guest_memory", + Level: alerts.AlertLevelWarning, + ResourceID: "guest:100", + ResourceName: "vm-100", + Value: 80, + Threshold: 75, + StartTime: now.Add(-30 * time.Second), + }, + } + resolved := []models.ResolvedAlert{ + { + Alert: models.Alert{ + ID: "r1", + Type: "storage_usage", + Level: "critical", + ResourceID: "storage:local", + ResourceName: "local", + StartTime: now.Add(-10 * time.Minute), + }, + ResolvedTime: now.Add(-2 * time.Minute), + }, + { + Alert: models.Alert{ + ID: "r2", + Type: "host_offline", + Level: "warning", + ResourceID: "host:h1", + ResourceName: "h1", + StartTime: now.Add(-2 * time.Hour), + }, + ResolvedTime: now.Add(-2 * time.Hour), + }, + } + + a := NewAlertManagerAdapter(&stubAlertManager{active: active, resolved: resolved}) + + gotActive := a.GetActiveAlerts() + if len(gotActive) != 2 { + t.Fatalf("GetActiveAlerts = %d, want 2", len(gotActive)) + } + if gotActive[0].ResourceType != "node" { + t.Fatalf("ResourceType = %q, want node", gotActive[0].ResourceType) + } + if gotActive[1].Duration == "" { + t.Fatalf("expected Duration to be populated") + } + + gotByResource := a.GetAlertsByResource("node:pve1") + if len(gotByResource) != 1 || gotByResource[0].ID != "a1" { + t.Fatalf("GetAlertsByResource = %+v", gotByResource) + } + + gotRecent := a.GetRecentlyResolved(30) + if len(gotRecent) != 1 || gotRecent[0].ID != "r1" { + t.Fatalf("GetRecentlyResolved = %+v", gotRecent) + } + + gotHistory := a.GetAlertHistory("storage:local", 1) + if len(gotHistory) != 1 || gotHistory[0].ID != "r1" { + t.Fatalf("GetAlertHistory = %+v", gotHistory) + } +} + diff --git a/internal/ai/alert_provider_test.go b/internal/ai/alert_provider_test.go new file mode 100644 index 0000000..8bbd661 --- /dev/null +++ b/internal/ai/alert_provider_test.go @@ -0,0 +1,167 @@ +package ai + +import ( + "strings" + "testing" + "time" +) + +type stubAlertProvider struct { + active []AlertInfo + resolved []ResolvedAlertInfo +} + +func (s *stubAlertProvider) GetActiveAlerts() []AlertInfo { return s.active } +func (s *stubAlertProvider) GetRecentlyResolved(minutes int) []ResolvedAlertInfo { return s.resolved } +func (s *stubAlertProvider) GetAlertsByResource(resourceID string) []AlertInfo { + out := make([]AlertInfo, 0) + for _, a := range s.active { + if a.ResourceID == resourceID { + out = append(out, a) + } + } + return out +} +func (s *stubAlertProvider) GetAlertHistory(resourceID string, limit int) []ResolvedAlertInfo { + out := make([]ResolvedAlertInfo, 0) + for _, a := range s.resolved { + if a.ResourceID == resourceID { + out = append(out, a) + if len(out) >= limit { + break + } + } + } + return out +} + +func TestService_buildAlertContext(t *testing.T) { + now := time.Now() + + s := &Service{} + s.SetAlertProvider(&stubAlertProvider{ + active: []AlertInfo{ + { + ID: "a1", + Type: "cpu", + Level: "critical", + ResourceID: "node:pve1", + ResourceName: "pve1", + ResourceType: "node", + Node: "pve1", + Message: "cpu high", + Value: 95, + Threshold: 80, + StartTime: now.Add(-5 * time.Minute), + Duration: "5 mins", + Acknowledged: true, + }, + { + ID: "a2", + Type: "memory", + Level: "warning", + ResourceID: "guest:100", + ResourceName: "vm-100", + ResourceType: "guest", + Message: "mem high", + Value: 80, + Threshold: 75, + StartTime: now.Add(-2 * time.Minute), + Duration: "2 mins", + }, + }, + resolved: []ResolvedAlertInfo{ + { + AlertInfo: AlertInfo{ + ID: "r1", + Type: "disk", + Level: "warning", + ResourceID: "storage:local", + ResourceName: "local", + Message: "disk ok", + Duration: "10 mins", + }, + ResolvedTime: now.Add(-2 * time.Minute), + Duration: "10 mins", + }, + }, + }) + + ctx := s.buildAlertContext() + if !strings.Contains(ctx, "## Alert Status") { + t.Fatalf("expected alert status header, got: %s", ctx) + } + if !strings.Contains(ctx, "### Active Alerts") || !strings.Contains(ctx, "**Critical:**") || !strings.Contains(ctx, "**Warning:**") { + t.Fatalf("expected active alert sections, got: %s", ctx) + } + if !strings.Contains(ctx, "[ACKNOWLEDGED]") || !strings.Contains(ctx, "on node pve1") { + t.Fatalf("expected acknowledged/node formatting, got: %s", ctx) + } + if !strings.Contains(ctx, "### Recently Resolved") { + t.Fatalf("expected recently resolved section, got: %s", ctx) + } +} + +func TestService_buildAlertContext_Empty(t *testing.T) { + s := &Service{} + if got := s.buildAlertContext(); got != "" { + t.Fatalf("expected empty string, got: %q", got) + } + s.SetAlertProvider(&stubAlertProvider{}) + if got := s.buildAlertContext(); got != "" { + t.Fatalf("expected empty string when no alerts, got: %q", got) + } +} + +func TestService_buildTargetAlertContext(t *testing.T) { + s := &Service{} + s.SetAlertProvider(&stubAlertProvider{ + active: []AlertInfo{ + {ID: "a1", Level: "critical", Type: "cpu", ResourceID: "node:pve1", ResourceName: "pve1", Duration: "1 min", Value: 90, Threshold: 80}, + {ID: "a2", Level: "warning", Type: "memory", ResourceID: "node:pve2", ResourceName: "pve2", Duration: "1 min", Value: 80, Threshold: 70}, + }, + }) + + got := s.buildTargetAlertContext("node:pve1") + if !strings.Contains(got, "Active Alerts for This Resource") || !strings.Contains(got, "pve1") { + t.Fatalf("unexpected context: %s", got) + } + if strings.Contains(got, "pve2") { + t.Fatalf("unexpected extra resource: %s", got) + } +} + +func TestFormatTimeAgo(t *testing.T) { + now := time.Now() + if got := formatTimeAgo(now.Add(-10 * time.Second)); got != "just now" { + t.Fatalf("formatTimeAgo(<1m) = %q", got) + } + if got := formatTimeAgo(now.Add(-2 * time.Minute)); got != "2 minutes" { + t.Fatalf("formatTimeAgo(2m) = %q", got) + } + if got := formatTimeAgo(now.Add(-2 * time.Hour)); got != "2 hours" { + t.Fatalf("formatTimeAgo(2h) = %q", got) + } + if got := formatTimeAgo(now.Add(-48 * time.Hour)); got != "2 days" { + t.Fatalf("formatTimeAgo(2d) = %q", got) + } +} + +func TestGenerateAlertInvestigationPrompt(t *testing.T) { + out := GenerateAlertInvestigationPrompt(AlertInvestigationRequest{ + Level: "critical", + ResourceName: "pve1", + ResourceType: "node", + AlertType: "cpu", + Value: 95, + Threshold: 80, + Duration: "5 mins", + Node: "pve1", + }) + if !strings.Contains(out, "Investigate this CRITICAL alert") || + !strings.Contains(out, "**Resource:** pve1 (node)") || + !strings.Contains(out, "**Node:** pve1") { + t.Fatalf("unexpected prompt: %s", out) + } +} + diff --git a/internal/ai/alert_threshold_adapter_test.go b/internal/ai/alert_threshold_adapter_test.go new file mode 100644 index 0000000..a426951 --- /dev/null +++ b/internal/ai/alert_threshold_adapter_test.go @@ -0,0 +1,55 @@ +package ai + +import ( + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/alerts" +) + +func TestAlertThresholdAdapter_Defaults(t *testing.T) { + a := NewAlertThresholdAdapter(nil) + if a.GetNodeCPUThreshold() != 80 { + t.Fatalf("GetNodeCPUThreshold default = %v", a.GetNodeCPUThreshold()) + } + if a.GetNodeMemoryThreshold() != 85 { + t.Fatalf("GetNodeMemoryThreshold default = %v", a.GetNodeMemoryThreshold()) + } + if a.GetGuestMemoryThreshold() != 85 { + t.Fatalf("GetGuestMemoryThreshold default = %v", a.GetGuestMemoryThreshold()) + } + if a.GetGuestDiskThreshold() != 90 { + t.Fatalf("GetGuestDiskThreshold default = %v", a.GetGuestDiskThreshold()) + } + if a.GetStorageThreshold() != 85 { + t.Fatalf("GetStorageThreshold default = %v", a.GetStorageThreshold()) + } +} + +func TestAlertThresholdAdapter_UsesAlertManagerConfig(t *testing.T) { + mgr := alerts.NewManager() + cfg := mgr.GetConfig() + cfg.NodeDefaults.CPU = &alerts.HysteresisThreshold{Trigger: 70, Clear: 65} + cfg.NodeDefaults.Memory = &alerts.HysteresisThreshold{Trigger: 75, Clear: 70} + cfg.GuestDefaults.Memory = &alerts.HysteresisThreshold{Trigger: 72, Clear: 70} + cfg.GuestDefaults.Disk = &alerts.HysteresisThreshold{Trigger: 91, Clear: 90} + cfg.StorageDefault.Trigger = 77 + mgr.UpdateConfig(cfg) + + a := NewAlertThresholdAdapter(mgr) + if a.GetNodeCPUThreshold() != 70 { + t.Fatalf("GetNodeCPUThreshold = %v", a.GetNodeCPUThreshold()) + } + if a.GetNodeMemoryThreshold() != 75 { + t.Fatalf("GetNodeMemoryThreshold = %v", a.GetNodeMemoryThreshold()) + } + if a.GetGuestMemoryThreshold() != 72 { + t.Fatalf("GetGuestMemoryThreshold = %v", a.GetGuestMemoryThreshold()) + } + if a.GetGuestDiskThreshold() != 91 { + t.Fatalf("GetGuestDiskThreshold = %v", a.GetGuestDiskThreshold()) + } + if a.GetStorageThreshold() != 77 { + t.Fatalf("GetStorageThreshold = %v", a.GetStorageThreshold()) + } +} + diff --git a/internal/ai/baseline_adapter_test.go b/internal/ai/baseline_adapter_test.go new file mode 100644 index 0000000..9873518 --- /dev/null +++ b/internal/ai/baseline_adapter_test.go @@ -0,0 +1,54 @@ +package ai + +import ( + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/ai/baseline" +) + +func TestBaselineStoreAdapter(t *testing.T) { + store := baseline.NewStore(baseline.StoreConfig{ + MinSamples: 1, + }) + + err := store.Learn("node:pve1", "node", "cpu", []baseline.MetricPoint{ + {Value: 10, Timestamp: time.Now().Add(-time.Minute)}, + {Value: 10, Timestamp: time.Now()}, + }) + if err != nil { + t.Fatalf("Learn: %v", err) + } + + adapter := NewBaselineStoreAdapter(store) + if adapter == nil { + t.Fatalf("expected adapter") + } + + mean, stddev, samples, ok := adapter.GetBaseline("node:pve1", "cpu") + if !ok { + t.Fatalf("expected baseline to exist") + } + if mean != 10 || stddev != 0 || samples != 2 { + t.Fatalf("unexpected baseline: mean=%v stddev=%v samples=%d", mean, stddev, samples) + } + + severity, z, gotMean, gotStd, ok := adapter.CheckAnomaly("node:pve1", "cpu", 11) + if !ok { + t.Fatalf("expected anomaly check ok") + } + if severity != "critical" || z <= 0 || gotMean != 10 || gotStd != 0 { + t.Fatalf("unexpected anomaly: severity=%q z=%v mean=%v stddev=%v", severity, z, gotMean, gotStd) + } +} + +func TestBaselineStoreAdapter_NilStore(t *testing.T) { + adapter := &BaselineStoreAdapter{} + if _, _, _, ok := adapter.GetBaseline("r", "m"); ok { + t.Fatalf("expected ok=false") + } + if _, _, _, _, ok := adapter.CheckAnomaly("r", "m", 1); ok { + t.Fatalf("expected ok=false") + } +} + diff --git a/internal/ai/providers/ollama_test.go b/internal/ai/providers/ollama_test.go index acff6ef..2e87108 100644 --- a/internal/ai/providers/ollama_test.go +++ b/internal/ai/providers/ollama_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "time" ) @@ -360,3 +361,145 @@ func TestOllamaClient_ListModels_Failure(t *testing.T) { t.Error("Expected error for failed list models") } } + +func TestNewOllamaClient_NormalizesBaseURL(t *testing.T) { + tests := []struct { + in string + expected string + }{ + {"", "http://localhost:11434"}, + {"http://example:11434", "http://example:11434"}, + {"http://example:11434/", "http://example:11434"}, + {"http://example:11434/api", "http://example:11434"}, + {"http://example:11434/api/", "http://example:11434"}, + } + + for _, tc := range tests { + t.Run(tc.in, func(t *testing.T) { + client := NewOllamaClient("llama3", tc.in) + if client.baseURL != tc.expected { + t.Fatalf("baseURL = %q, want %q", client.baseURL, tc.expected) + } + }) + } +} + +func TestOllamaClient_Chat_ToolCallsResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := ollamaResponse{ + Model: "llama3", + Message: ollamaMessageResp{ + Role: "assistant", + Content: "", + ToolCalls: []ollamaToolCall{ + { + ID: "call_1", + Function: ollamaFunctionCall{ + Name: "get_time", + Arguments: map[string]interface{}{"tz": "UTC"}, + }, + }, + }, + }, + Done: true, + DoneReason: "stop", + } + _ = json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewOllamaClient("llama3", server.URL) + out, err := client.Chat(context.Background(), ChatRequest{ + Messages: []Message{{Role: "user", Content: "What time is it?"}}, + }) + if err != nil { + t.Fatalf("Chat: %v", err) + } + if out.StopReason != "tool_use" { + t.Fatalf("StopReason = %q, want tool_use", out.StopReason) + } + if len(out.ToolCalls) != 1 { + t.Fatalf("ToolCalls = %d, want 1", len(out.ToolCalls)) + } + if out.ToolCalls[0].ID != "call_1" || out.ToolCalls[0].Name != "get_time" { + t.Fatalf("unexpected tool call: %+v", out.ToolCalls[0]) + } + if out.ToolCalls[0].Input["tz"] != "UTC" { + t.Fatalf("unexpected tool call input: %+v", out.ToolCalls[0].Input) + } +} + +func TestOllamaClient_Chat_ToolCallsAndToolResultsInRequest(t *testing.T) { + var got ollamaRequest + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewDecoder(r.Body).Decode(&got) + _ = json.NewEncoder(w).Encode(ollamaResponse{ + Model: got.Model, + Message: ollamaMessageResp{Role: "assistant", Content: "ok"}, + Done: true, + }) + })) + defer server.Close() + + client := NewOllamaClient("llama3", server.URL) + _, err := client.Chat(context.Background(), ChatRequest{ + System: "system prompt", + Messages: []Message{ + { + Role: "assistant", + Content: "calling tool", + ToolCalls: []ToolCall{ + {Name: "get_time", Input: map[string]any{"tz": "UTC"}}, + }, + }, + { + Role: "assistant", + ToolResult: &ToolResult{Content: "{\"time\":\"00:00\"}"}, + }, + }, + Tools: []Tool{ + { + Type: "function", + Name: "get_time", + Description: "get time", + InputSchema: map[string]any{"type": "object"}, + }, + { + Type: "web_search", + Name: "search", + }, + }, + }) + if err != nil { + t.Fatalf("Chat: %v", err) + } + + if got.Messages[0].Role != "system" || got.Messages[0].Content != "system prompt" { + t.Fatalf("expected system message first, got: %+v", got.Messages[0]) + } + + var sawAssistantToolCall bool + var sawToolResult bool + for _, m := range got.Messages { + if m.Role == "assistant" && len(m.ToolCalls) == 1 && m.ToolCalls[0].Function.Name == "get_time" { + if m.ToolCalls[0].Function.Arguments["tz"] != "UTC" { + t.Fatalf("unexpected tool call args: %+v", m.ToolCalls[0].Function.Arguments) + } + sawAssistantToolCall = true + } + if m.Role == "tool" && strings.Contains(m.Content, "00:00") { + sawToolResult = true + } + } + if !sawAssistantToolCall { + t.Fatalf("expected assistant tool call message in request, got: %+v", got.Messages) + } + if !sawToolResult { + t.Fatalf("expected tool result message in request, got: %+v", got.Messages) + } + + if len(got.Tools) != 1 || got.Tools[0].Function.Name != "get_time" { + t.Fatalf("expected only function tools to be included, got: %+v", got.Tools) + } +} diff --git a/internal/api/ai_handlers_test.go b/internal/api/ai_handlers_test.go new file mode 100644 index 0000000..985f909 --- /dev/null +++ b/internal/api/ai_handlers_test.go @@ -0,0 +1,302 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" +) + +func TestAISettingsHandler_GetAndUpdateSettings_RoundTrip(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + + handler := NewAISettingsHandler(cfg, persistence, nil) + + // GET should return defaults if no config has been saved yet. + { + req := httptest.NewRequest(http.MethodGet, "/api/settings/ai", nil) + rec := httptest.NewRecorder() + handler.HandleGetAISettings(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("GET status = %d, body=%s", rec.Code, rec.Body.String()) + } + + var resp AISettingsResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Enabled { + t.Fatalf("expected default Enabled=false, got %+v", resp) + } + } + + // Update settings to enable AI via Ollama. + { + body, _ := json.Marshal(AISettingsUpdateRequest{ + Enabled: ptr(true), + Provider: ptr("ollama"), + Model: ptr("ollama:llama3"), + OllamaBaseURL: ptr("http://localhost:11434"), + }) + req := httptest.NewRequest(http.MethodPut, "/api/settings/ai", bytes.NewReader(body)) + rec := httptest.NewRecorder() + handler.HandleUpdateAISettings(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("PUT status = %d, body=%s", rec.Code, rec.Body.String()) + } + + var resp AISettingsResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if !resp.Enabled || !resp.OllamaConfigured { + t.Fatalf("expected enabled + ollama configured, got %+v", resp) + } + if resp.OllamaBaseURL != "http://localhost:11434" { + t.Fatalf("unexpected ollama base url: %+v", resp) + } + } + + // GET again should reflect persisted updates. + { + req := httptest.NewRequest(http.MethodGet, "/api/settings/ai", nil) + rec := httptest.NewRecorder() + handler.HandleGetAISettings(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("GET status = %d, body=%s", rec.Code, rec.Body.String()) + } + + var resp AISettingsResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if !resp.Enabled || !resp.OllamaConfigured { + t.Fatalf("expected enabled + ollama configured, got %+v", resp) + } + } +} + +func TestAISettingsHandler_ListModels_Ollama(t *testing.T) { + t.Parallel() + + ollama := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/tags": + _ = json.NewEncoder(w).Encode(map[string]any{ + "models": []map[string]any{ + {"name": "llama3:latest"}, + {"name": "tinyllama:latest"}, + }, + }) + default: + http.NotFound(w, r) + } + })) + defer ollama.Close() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + + aiCfg := config.NewDefaultAIConfig() + aiCfg.Enabled = true + aiCfg.Model = "ollama:llama3" + aiCfg.OllamaBaseURL = ollama.URL + if err := persistence.SaveAIConfig(*aiCfg); err != nil { + t.Fatalf("SaveAIConfig: %v", err) + } + + handler := NewAISettingsHandler(cfg, persistence, nil) + + req := httptest.NewRequest(http.MethodGet, "/api/ai/models", nil) + rec := httptest.NewRecorder() + handler.HandleListModels(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) + } + + var resp struct { + Models []struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"models"` + Error string `json:"error"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Error != "" { + t.Fatalf("unexpected error: %s", resp.Error) + } + if len(resp.Models) != 2 { + t.Fatalf("expected 2 models, got %+v", resp.Models) + } +} + +func TestAISettingsHandler_Execute_Ollama(t *testing.T) { + t.Parallel() + + ollama := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/chat": + _ = json.NewEncoder(w).Encode(map[string]any{ + "model": "llama3", + "created_at": time.Now().Format(time.RFC3339), + "message": map[string]any{ + "role": "assistant", + "content": "hello from ollama", + }, + "done": true, + "done_reason": "stop", + "prompt_eval_count": 3, + "eval_count": 5, + }) + case "/api/version": + _ = json.NewEncoder(w).Encode(map[string]any{"version": "0.1.0"}) + case "/api/tags": + _ = json.NewEncoder(w).Encode(map[string]any{"models": []map[string]any{{"name": "llama3"}}}) + default: + http.NotFound(w, r) + } + })) + defer ollama.Close() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + + aiCfg := config.NewDefaultAIConfig() + aiCfg.Enabled = true + aiCfg.Model = "ollama:llama3" + aiCfg.OllamaBaseURL = ollama.URL + if err := persistence.SaveAIConfig(*aiCfg); err != nil { + t.Fatalf("SaveAIConfig: %v", err) + } + + handler := NewAISettingsHandler(cfg, persistence, nil) + + body, _ := json.Marshal(AIExecuteRequest{Prompt: "hi"}) + req := httptest.NewRequest(http.MethodPost, "/api/ai/execute", bytes.NewReader(body)) + req = req.WithContext(context.Background()) + rec := httptest.NewRecorder() + handler.HandleExecute(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) + } + + var resp AIExecuteResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Content != "hello from ollama" || resp.Model == "" { + t.Fatalf("unexpected response: %+v", resp) + } +} + +func ptr[T any](v T) *T { return &v } + +func TestAISettingsHandler_TestConnection_Ollama(t *testing.T) { + t.Parallel() + + ollama := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/version" { + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"version": "0.1.0"}) + })) + defer ollama.Close() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + + aiCfg := config.NewDefaultAIConfig() + aiCfg.Enabled = true + aiCfg.Model = "ollama:llama3" + aiCfg.OllamaBaseURL = ollama.URL + if err := persistence.SaveAIConfig(*aiCfg); err != nil { + t.Fatalf("SaveAIConfig: %v", err) + } + + handler := NewAISettingsHandler(cfg, persistence, nil) + + req := httptest.NewRequest(http.MethodPost, "/api/ai/test", nil) + rec := httptest.NewRecorder() + handler.HandleTestAIConnection(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) + } + + var resp struct { + Success bool `json:"success"` + Message string `json:"message"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if !resp.Success { + t.Fatalf("expected success, got %+v", resp) + } +} + +func TestAISettingsHandler_TestProvider_Ollama(t *testing.T) { + t.Parallel() + + ollama := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/version" { + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"version": "0.1.0"}) + })) + defer ollama.Close() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + + aiCfg := config.NewDefaultAIConfig() + aiCfg.Enabled = true + aiCfg.Model = "ollama:llama3" + aiCfg.OllamaBaseURL = ollama.URL + if err := persistence.SaveAIConfig(*aiCfg); err != nil { + t.Fatalf("SaveAIConfig: %v", err) + } + + handler := NewAISettingsHandler(cfg, persistence, nil) + + req := httptest.NewRequest(http.MethodPost, "/api/ai/test/ollama", nil) + rec := httptest.NewRecorder() + handler.HandleTestProvider(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String()) + } + + var resp struct { + Success bool `json:"success"` + Provider string `json:"provider"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if !resp.Success || resp.Provider != "ollama" { + t.Fatalf("unexpected response: %+v", resp) + } +} diff --git a/internal/kubernetesagent/agent.go b/internal/kubernetesagent/agent.go index 29e0f01..1b89708 100644 --- a/internal/kubernetesagent/agent.go +++ b/internal/kubernetesagent/agent.go @@ -55,7 +55,7 @@ type Agent struct { logger zerolog.Logger httpClient *http.Client - kubeClient *kubernetes.Clientset + kubeClient kubernetes.Interface restCfg *rest.Config agentID string diff --git a/internal/kubernetesagent/agent_test.go b/internal/kubernetesagent/agent_test.go new file mode 100644 index 0000000..7931e79 --- /dev/null +++ b/internal/kubernetesagent/agent_test.go @@ -0,0 +1,276 @@ +package kubernetesagent + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + agentsk8s "github.com/rcourtman/pulse-go-rewrite/pkg/agents/kubernetes" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" +) + +func TestBuildRESTConfig_ExplicitKubeconfig(t *testing.T) { + tmp := t.TempDir() + kubeconfigPath := filepath.Join(tmp, "config") + + kubeconfig := ` +apiVersion: v1 +kind: Config +clusters: +- name: c1 + cluster: + server: https://k8s.example.invalid +contexts: +- name: ctx1 + context: + cluster: c1 + user: u1 +- name: ctx2 + context: + cluster: c1 + user: u1 +current-context: ctx1 +users: +- name: u1 + user: + token: test +` + if err := os.WriteFile(kubeconfigPath, []byte(strings.TrimSpace(kubeconfig)), 0o600); err != nil { + t.Fatalf("write kubeconfig: %v", err) + } + + restCfg, ctxName, err := buildRESTConfig(kubeconfigPath, "ctx2") + if err != nil { + t.Fatalf("buildRESTConfig: %v", err) + } + if restCfg.Host != "https://k8s.example.invalid" { + t.Fatalf("restCfg.Host = %q", restCfg.Host) + } + if ctxName != "ctx2" { + t.Fatalf("contextName = %q", ctxName) + } +} + +func TestNamespaceAllowed_IncludeExclude(t *testing.T) { + a := &Agent{ + includeNamespaces: makeNamespaceSet([]string{"a", "b"}), + excludeNamespaces: makeNamespaceSet([]string{"b"}), + } + + if !a.namespaceAllowed("a") { + t.Fatalf("expected namespace a allowed") + } + if a.namespaceAllowed("b") { + t.Fatalf("expected namespace b excluded") + } + if a.namespaceAllowed("") { + t.Fatalf("expected empty namespace disallowed") + } +} + +func TestRolesFromNodeLabels(t *testing.T) { + roles := rolesFromNodeLabels(map[string]string{ + "node-role.kubernetes.io/master": "", + "kubernetes.io/role": "worker", + }) + if len(roles) != 2 || roles[0] != "master" || roles[1] != "worker" { + t.Fatalf("unexpected roles: %+v", roles) + } +} + +func TestCollectPods_FiltersProblemsAndSorts(t *testing.T) { + clientset := fake.NewSimpleClientset( + &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Namespace: "a", Name: "ok"}, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + ContainerStatuses: []corev1.ContainerStatus{ + {Name: "c", Ready: true, State: corev1.ContainerState{Running: &corev1.ContainerStateRunning{}}}, + }, + }, + }, + &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Namespace: "a", Name: "pending"}, + Status: corev1.PodStatus{Phase: corev1.PodPending}, + }, + &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Namespace: "b", Name: "not-ready"}, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + ContainerStatuses: []corev1.ContainerStatus{ + {Name: "c", Ready: false, State: corev1.ContainerState{Running: &corev1.ContainerStateRunning{}}}, + }, + }, + }, + &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Namespace: "b", Name: "failed"}, + Status: corev1.PodStatus{Phase: corev1.PodFailed, Reason: "CrashLoopBackOff"}, + }, + ) + + a := &Agent{ + cfg: Config{ + MaxPods: 2, + IncludeAllPods: false, + }, + kubeClient: clientset, + includeNamespaces: makeNamespaceSet(nil), + excludeNamespaces: makeNamespaceSet(nil), + } + + pods, err := a.collectPods(context.Background()) + if err != nil { + t.Fatalf("collectPods: %v", err) + } + if len(pods) != 2 { + t.Fatalf("expected MaxPods=2, got %d (%+v)", len(pods), pods) + } + if pods[0].Namespace != "a" || pods[0].Name != "pending" { + t.Fatalf("unexpected first pod: %+v", pods[0]) + } + if pods[1].Namespace != "b" { + t.Fatalf("unexpected second pod: %+v", pods[1]) + } +} + +func TestCollectDeployments_FiltersProblems(t *testing.T) { + replicas := int32(3) + okReplicas := int32(2) + + clientset := fake.NewSimpleClientset( + &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Namespace: "a", Name: "bad"}, + Spec: appsv1.DeploymentSpec{Replicas: &replicas}, + Status: appsv1.DeploymentStatus{AvailableReplicas: 2, ReadyReplicas: 2, UpdatedReplicas: 2}, + }, + &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Namespace: "a", Name: "ok"}, + Spec: appsv1.DeploymentSpec{Replicas: &okReplicas}, + Status: appsv1.DeploymentStatus{AvailableReplicas: 2, ReadyReplicas: 2, UpdatedReplicas: 2}, + }, + &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Namespace: "a", Name: "disabled"}, + Spec: appsv1.DeploymentSpec{Replicas: nil}, + Status: appsv1.DeploymentStatus{AvailableReplicas: 0, ReadyReplicas: 0, UpdatedReplicas: 0}, + }, + ) + + a := &Agent{ + cfg: Config{IncludeAllPods: false}, + kubeClient: clientset, + includeNamespaces: makeNamespaceSet(nil), + excludeNamespaces: makeNamespaceSet(nil), + } + + deps, err := a.collectDeployments(context.Background()) + if err != nil { + t.Fatalf("collectDeployments: %v", err) + } + if len(deps) != 1 { + t.Fatalf("expected 1 deployment, got %d (%+v)", len(deps), deps) + } + if deps[0].Name != "bad" { + t.Fatalf("unexpected deployment: %+v", deps[0]) + } +} + +func TestCollectNodes_MapsReadyRolesAndResources(t *testing.T) { + clientset := fake.NewSimpleClientset( + &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "n1", + UID: "uid1", + Labels: map[string]string{ + "node-role.kubernetes.io/master": "", + }, + }, + Spec: corev1.NodeSpec{Unschedulable: true}, + Status: corev1.NodeStatus{ + NodeInfo: corev1.NodeSystemInfo{ + KubeletVersion: "v1.30.0", + ContainerRuntimeVersion: "containerd://1.7.0", + OSImage: "linux", + KernelVersion: "6.0", + Architecture: "amd64", + }, + Conditions: []corev1.NodeCondition{ + {Type: corev1.NodeReady, Status: corev1.ConditionTrue}, + }, + Capacity: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("4"), + corev1.ResourceMemory: resource.MustParse("8Gi"), + corev1.ResourcePods: resource.MustParse("110"), + }, + Allocatable: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("3"), + corev1.ResourceMemory: resource.MustParse("7Gi"), + corev1.ResourcePods: resource.MustParse("100"), + }, + }, + }, + ) + + a := &Agent{kubeClient: clientset} + nodes, err := a.collectNodes(context.Background()) + if err != nil { + t.Fatalf("collectNodes: %v", err) + } + if len(nodes) != 1 { + t.Fatalf("expected 1 node, got %d", len(nodes)) + } + n := nodes[0] + if !n.Ready || !n.Unschedulable { + t.Fatalf("unexpected ready/unschedulable: %+v", n) + } + if n.Capacity.CPUCores != 4 || n.Allocatable.CPUCores != 3 { + t.Fatalf("unexpected cpu: %+v", n) + } + if len(n.Roles) != 1 || n.Roles[0] != "master" { + t.Fatalf("unexpected roles: %+v", n.Roles) + } +} + +func TestSendReport_SetsHeadersAndHandlesStatus(t *testing.T) { + var sawAuth bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/agents/kubernetes/report" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") { + t.Fatalf("Authorization = %q", r.Header.Get("Authorization")) + } + if r.Header.Get("X-API-Token") != "token" { + t.Fatalf("X-API-Token = %q", r.Header.Get("X-API-Token")) + } + if r.Header.Get("User-Agent") != reportUserAgent+"1.2.3" { + t.Fatalf("User-Agent = %q", r.Header.Get("User-Agent")) + } + sawAuth = true + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + a := &Agent{ + cfg: Config{APIToken: "token"}, + httpClient: server.Client(), + pulseURL: server.URL, + agentVersion: "1.2.3", + } + + if err := a.sendReport(context.Background(), agentsk8s.Report{Timestamp: time.Now().UTC()}); err != nil { + t.Fatalf("sendReport: %v", err) + } + if !sawAuth { + t.Fatalf("expected server to receive request") + } +} + diff --git a/pkg/pbs/client_http_test.go b/pkg/pbs/client_http_test.go new file mode 100644 index 0000000..4702e6d --- /dev/null +++ b/pkg/pbs/client_http_test.go @@ -0,0 +1,183 @@ +package pbs + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestNewClient_TokenAuth_SetsAuthorizationHeader(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api2/json/version" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "PBSAPIToken=root@pam!pulse-token:secret" { + t.Fatalf("Authorization = %q", got) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": Version{Version: "3.0", Release: "1", Repoid: "abc"}, + }) + })) + defer server.Close() + + client, err := NewClient(ClientConfig{ + Host: server.URL, + TokenName: "root@pam!pulse-token", + TokenValue: "secret", + Timeout: 2 * time.Second, + }) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + + v, err := client.GetVersion(context.Background()) + if err != nil { + t.Fatalf("GetVersion: %v", err) + } + if v.Version != "3.0" { + t.Fatalf("unexpected version: %+v", v) + } +} + +func TestNewClient_PasswordAuth_FallsBackToFormOnUnsupportedMediaType(t *testing.T) { + var calls int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api2/json/access/ticket" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + + switch atomic.AddInt32(&calls, 1) { + case 1: + if ct := r.Header.Get("Content-Type"); !strings.Contains(ct, "application/json") { + t.Fatalf("expected json content-type, got %q", ct) + } + w.WriteHeader(http.StatusUnsupportedMediaType) + _, _ = w.Write([]byte("unsupported")) + case 2: + if ct := r.Header.Get("Content-Type"); !strings.Contains(ct, "application/x-www-form-urlencoded") { + t.Fatalf("expected form content-type, got %q", ct) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{ + "ticket": "ticket123", + "CSRFPreventionToken": "csrf456", + }, + }) + default: + t.Fatalf("unexpected call count") + } + })) + defer server.Close() + + client, err := NewClient(ClientConfig{ + Host: server.URL, + User: "root@pam", + Password: "password", + Timeout: 2 * time.Second, + }) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + if client.auth.ticket != "ticket123" || client.auth.csrfToken != "csrf456" { + t.Fatalf("expected auth fields to be set, got ticket=%q csrf=%q", client.auth.ticket, client.auth.csrfToken) + } +} + +func TestClient_request_SendsTicketAndCSRFToken(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api2/json/test" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if got := r.Header.Get("Cookie"); got != "PBSAuthCookie=ticket123" { + t.Fatalf("Cookie = %q", got) + } + if got := r.Header.Get("CSRFPreventionToken"); got != "csrf456" { + t.Fatalf("CSRFPreventionToken = %q", got) + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client, err := NewClient(ClientConfig{ + Host: server.URL, + User: "root@pam", + Timeout: 2 * time.Second, + }) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + + client.auth.ticket = "ticket123" + client.auth.csrfToken = "csrf456" + client.auth.expiresAt = time.Now().Add(time.Hour) + + resp, err := client.request(context.Background(), http.MethodPost, "/test", url.Values{"a": {"b"}}) + if err != nil { + t.Fatalf("request: %v", err) + } + _ = resp.Body.Close() +} + +func TestClient_GetNodeStatus_PermissionDeniedReturnsNil(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api2/json/nodes/localhost/status" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte("permission denied")) + })) + defer server.Close() + + client, err := NewClient(ClientConfig{ + Host: server.URL, + TokenName: "root@pam!pulse-token", + TokenValue: "secret", + Timeout: 2 * time.Second, + }) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + + status, err := client.GetNodeStatus(context.Background()) + if err != nil { + t.Fatalf("GetNodeStatus: %v", err) + } + if status != nil { + t.Fatalf("expected nil status on permission error, got: %+v", status) + } +} + +func TestClient_GetDatastores_HTMLResponseOnHTTP(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api2/json/admin/datastore" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("error")) + })) + defer server.Close() + + client, err := NewClient(ClientConfig{ + Host: server.URL, // http://... + TokenName: "root@pam!pulse-token", + TokenValue: "secret", + Timeout: 2 * time.Second, + }) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + + _, err = client.GetDatastores(context.Background()) + if err == nil { + t.Fatalf("expected error") + } + if !strings.Contains(err.Error(), "Try changing your URL") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/pkg/proxmox/zfs_details_test.go b/pkg/proxmox/zfs_details_test.go new file mode 100644 index 0000000..e467ea0 --- /dev/null +++ b/pkg/proxmox/zfs_details_test.go @@ -0,0 +1,97 @@ +package proxmox + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestClient_GetZFSPoolsWithDetails_MergesDetailWhenAvailable(t *testing.T) { + t.Helper() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") == "" { + t.Fatalf("expected Authorization header to be set") + } + + switch r.URL.Path { + case "/api2/json/nodes/pve1/disks/zfs": + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": []ZFSPoolStatus{ + {Name: "rpool", Health: "ONLINE", Size: 100, Alloc: 60, Free: 40, Frag: 1, Dedup: 1.0}, + {Name: "data", Health: "DEGRADED", Size: 200, Alloc: 150, Free: 50, Frag: 10, Dedup: 1.2}, + }, + }) + return + case "/api2/json/nodes/pve1/disks/zfs/rpool": + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": ZFSPoolDetail{ + Name: "rpool", + State: "ONLINE", + Status: "ok", + Scan: "none requested", + Errors: "No known data errors", + Children: []ZFSPoolDevice{ + { + Name: "mirror-0", + State: "DEGRADED", + Leaf: 0, + Children: []ZFSPoolDevice{ + {Name: "sda", State: "ONLINE", Leaf: 1, Read: 1}, + {Name: "sdb", State: "ONLINE", Leaf: 1}, + }, + }, + }, + }, + }) + return + case "/api2/json/nodes/pve1/disks/zfs/data": + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("boom")) + return + default: + t.Fatalf("unexpected path: %s", r.URL.Path) + } + })) + defer server.Close() + + client, err := NewClient(ClientConfig{ + Host: server.URL, + TokenName: "root@pam!pulse-token", + TokenValue: "secret", + Timeout: 2 * time.Second, + }) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + + pools, err := client.GetZFSPoolsWithDetails(context.Background(), "pve1") + if err != nil { + t.Fatalf("GetZFSPoolsWithDetails: %v", err) + } + if len(pools) != 2 { + t.Fatalf("expected 2 pools, got %d", len(pools)) + } + + var rpool ZFSPoolInfo + var dataPool ZFSPoolInfo + for _, p := range pools { + switch p.Name { + case "rpool": + rpool = p + case "data": + dataPool = p + } + } + + if rpool.State != "ONLINE" || rpool.Status != "ok" || rpool.Errors == "" || len(rpool.Devices) == 0 { + t.Fatalf("expected details to be applied to rpool, got: %+v", rpool) + } + if dataPool.State != "" || dataPool.Status != "" || dataPool.Errors != "" || len(dataPool.Devices) != 0 { + t.Fatalf("expected data pool to fall back to list-only info, got: %+v", dataPool) + } +} + diff --git a/pkg/tlsutil/dnscache_test.go b/pkg/tlsutil/dnscache_test.go new file mode 100644 index 0000000..27f80fd --- /dev/null +++ b/pkg/tlsutil/dnscache_test.go @@ -0,0 +1,61 @@ +package tlsutil + +import ( + "context" + "net" + "testing" + "time" +) + +func TestDialContextWithCache_Success(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer ln.Close() + + done := make(chan struct{}) + go func() { + defer close(done) + conn, err := ln.Accept() + if err == nil { + _ = conn.Close() + } + }() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + conn, err := DialContextWithCache(ctx, "tcp", ln.Addr().String()) + if err != nil { + t.Fatalf("DialContextWithCache: %v", err) + } + _ = conn.Close() + + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatalf("server did not accept connection") + } +} + +func TestDialContextWithCache_BadAddress(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + _, err := DialContextWithCache(ctx, "tcp", "missing-port") + if err == nil { + t.Fatalf("expected error") + } +} + +func TestDialContextWithCache_UnresolvableHost(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + _, err := DialContextWithCache(ctx, "tcp", "this-domain-should-not-exist.invalid:80") + if err == nil { + t.Fatalf("expected error") + } +} +