test: add unit tests for AI, Kubernetes agent, and clients

This commit is contained in:
rcourtman 2025-12-17 12:47:36 +00:00
parent ad59c13b95
commit 941e9ed098
12 changed files with 1455 additions and 3 deletions

View file

@ -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}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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())
}
}

View file

@ -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")
}
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -55,7 +55,7 @@ type Agent struct {
logger zerolog.Logger
httpClient *http.Client
kubeClient *kubernetes.Clientset
kubeClient kubernetes.Interface
restCfg *rest.Config
agentID string

View file

@ -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")
}
}

183
pkg/pbs/client_http_test.go Normal file
View file

@ -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("<html><body>error</body></html>"))
}))
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)
}
}

View file

@ -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)
}
}

View file

@ -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")
}
}