diff --git a/internal/ai/providers/gemini_test.go b/internal/ai/providers/gemini_test.go new file mode 100644 index 0000000..4ec84c9 --- /dev/null +++ b/internal/ai/providers/gemini_test.go @@ -0,0 +1,529 @@ +package providers + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestNewGeminiClient(t *testing.T) { + t.Parallel() + + client := NewGeminiClient("test-api-key", "gemini-pro", "") + if client == nil { + t.Fatal("expected non-nil client") + } + if client.apiKey != "test-api-key" { + t.Errorf("expected apiKey 'test-api-key', got %q", client.apiKey) + } + if client.model != "gemini-pro" { + t.Errorf("expected model 'gemini-pro', got %q", client.model) + } + if client.baseURL != geminiAPIURL { + t.Errorf("expected default baseURL, got %q", client.baseURL) + } +} + +func TestNewGeminiClient_StripPrefix(t *testing.T) { + t.Parallel() + + client := NewGeminiClient("api-key", "gemini:gemini-1.5-pro", "") + if client.model != "gemini-1.5-pro" { + t.Errorf("expected model with prefix stripped, got %q", client.model) + } +} + +func TestNewGeminiClient_CustomBaseURL(t *testing.T) { + t.Parallel() + + client := NewGeminiClient("api-key", "gemini-pro", "https://custom.api.example.com") + if client.baseURL != "https://custom.api.example.com" { + t.Errorf("expected custom baseURL, got %q", client.baseURL) + } +} + +func TestGeminiClient_Name(t *testing.T) { + t.Parallel() + + client := NewGeminiClient("key", "model", "") + if client.Name() != "gemini" { + t.Errorf("expected Name() to return 'gemini', got %q", client.Name()) + } +} + +func TestGeminiClient_Chat_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("expected POST, got %s", r.Method) + } + if !strings.Contains(r.URL.Path, "generateContent") { + t.Errorf("expected generateContent in path, got %s", r.URL.Path) + } + + resp := geminiResponse{ + Candidates: []geminiCandidate{ + { + Content: geminiContent{ + Parts: []geminiPart{ + {Text: "Hello! I'm Gemini."}, + }, + }, + FinishReason: "STOP", + }, + }, + UsageMetadata: &geminiUsageMetadata{ + PromptTokenCount: 10, + CandidatesTokenCount: 20, + TotalTokenCount: 30, + }, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewGeminiClient("test-key", "gemini-pro", server.URL) + + ctx := context.Background() + resp, err := client.Chat(ctx, ChatRequest{ + Messages: []Message{{Role: "user", Content: "Hello"}}, + }) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if resp.Content != "Hello! I'm Gemini." { + t.Errorf("expected content 'Hello! I'm Gemini.', got %q", resp.Content) + } + + if resp.InputTokens != 10 { + t.Errorf("expected 10 input tokens, got %d", resp.InputTokens) + } + + if resp.OutputTokens != 20 { + t.Errorf("expected 20 output tokens, got %d", resp.OutputTokens) + } + + if resp.StopReason != "end_turn" { + t.Errorf("expected stop reason 'end_turn', got %q", resp.StopReason) + } +} + +func TestGeminiClient_Chat_WithSystemPrompt(t *testing.T) { + var receivedReq geminiRequest + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewDecoder(r.Body).Decode(&receivedReq) + + resp := geminiResponse{ + Candidates: []geminiCandidate{ + { + Content: geminiContent{ + Parts: []geminiPart{{Text: "Response"}}, + }, + FinishReason: "STOP", + }, + }, + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewGeminiClient("test-key", "gemini-pro", server.URL) + + ctx := context.Background() + _, err := client.Chat(ctx, ChatRequest{ + Messages: []Message{{Role: "user", Content: "Hello"}}, + System: "You are a helpful assistant", + }) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if receivedReq.SystemInstruction == nil { + t.Fatal("expected system instruction to be set") + } + if len(receivedReq.SystemInstruction.Parts) == 0 { + t.Fatal("expected system instruction parts") + } + if receivedReq.SystemInstruction.Parts[0].Text != "You are a helpful assistant" { + t.Errorf("expected system instruction text, got %q", receivedReq.SystemInstruction.Parts[0].Text) + } +} + +func TestGeminiClient_Chat_WithMaxTokens(t *testing.T) { + var receivedReq geminiRequest + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewDecoder(r.Body).Decode(&receivedReq) + + resp := geminiResponse{ + Candidates: []geminiCandidate{ + { + Content: geminiContent{ + Parts: []geminiPart{{Text: "Response"}}, + }, + FinishReason: "STOP", + }, + }, + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewGeminiClient("test-key", "gemini-pro", server.URL) + + ctx := context.Background() + _, err := client.Chat(ctx, ChatRequest{ + Messages: []Message{{Role: "user", Content: "Hello"}}, + MaxTokens: 1024, + }) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if receivedReq.GenerationConfig == nil { + t.Fatal("expected generation config to be set") + } + if receivedReq.GenerationConfig.MaxOutputTokens != 1024 { + t.Errorf("expected max tokens 1024, got %d", receivedReq.GenerationConfig.MaxOutputTokens) + } +} + +func TestGeminiClient_Chat_ToolCalls(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := geminiResponse{ + Candidates: []geminiCandidate{ + { + Content: geminiContent{ + Parts: []geminiPart{ + { + FunctionCall: &geminiFunctionCall{ + Name: "get_weather", + Args: map[string]interface{}{"location": "NYC"}, + }, + }, + }, + }, + FinishReason: "STOP", + }, + }, + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewGeminiClient("test-key", "gemini-pro", server.URL) + + ctx := context.Background() + resp, err := client.Chat(ctx, ChatRequest{ + Messages: []Message{{Role: "user", Content: "What's the weather in NYC?"}}, + Tools: []Tool{ + { + Type: "function", + Name: "get_weather", + Description: "Get weather info", + }, + }, + }) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if resp.StopReason != "tool_use" { + t.Errorf("expected stop reason 'tool_use', got %q", resp.StopReason) + } + + if len(resp.ToolCalls) != 1 { + t.Fatalf("expected 1 tool call, got %d", len(resp.ToolCalls)) + } + + if resp.ToolCalls[0].Name != "get_weather" { + t.Errorf("expected tool name 'get_weather', got %q", resp.ToolCalls[0].Name) + } +} + +func TestGeminiClient_Chat_APIError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(geminiError{ + Error: struct { + Code int `json:"code"` + Message string `json:"message"` + Status string `json:"status"` + }{ + Code: 401, + Message: "Invalid API key", + Status: "UNAUTHENTICATED", + }, + }) + })) + defer server.Close() + + client := NewGeminiClient("invalid-key", "gemini-pro", server.URL) + + ctx := context.Background() + _, err := client.Chat(ctx, ChatRequest{ + Messages: []Message{{Role: "user", Content: "Hello"}}, + }) + + if err == nil { + t.Error("expected error for invalid API key") + } + if !strings.Contains(err.Error(), "401") { + t.Errorf("expected error to contain status code, got %v", err) + } +} + +func TestGeminiClient_Chat_NoCandidates(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := geminiResponse{ + Candidates: []geminiCandidate{}, + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewGeminiClient("test-key", "gemini-pro", server.URL) + + ctx := context.Background() + _, err := client.Chat(ctx, ChatRequest{ + Messages: []Message{{Role: "user", Content: "Hello"}}, + }) + + if err == nil { + t.Error("expected error for no candidates") + } + if !strings.Contains(err.Error(), "no response candidates") { + t.Errorf("expected 'no response candidates' error, got %v", err) + } +} + +func TestGeminiClient_Chat_SafetyBlocked(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := geminiResponse{ + Candidates: []geminiCandidate{ + { + Content: geminiContent{Parts: []geminiPart{}}, + FinishReason: "SAFETY", + SafetyRatings: []geminySafety{ + {Category: "HARM_CATEGORY_DANGEROUS", Probability: "HIGH", Blocked: true}, + }, + }, + }, + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewGeminiClient("test-key", "gemini-pro", server.URL) + + ctx := context.Background() + _, err := client.Chat(ctx, ChatRequest{ + Messages: []Message{{Role: "user", Content: "Something dangerous"}}, + }) + + if err == nil { + t.Error("expected error for safety blocked content") + } + if !strings.Contains(err.Error(), "safety") { + t.Errorf("expected 'safety' in error, got %v", err) + } +} + +func TestGeminiClient_Chat_PromptBlocked(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := geminiResponse{ + PromptFeedback: &geminiPromptFeedback{ + BlockReason: "SAFETY", + }, + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewGeminiClient("test-key", "gemini-pro", server.URL) + + ctx := context.Background() + _, err := client.Chat(ctx, ChatRequest{ + Messages: []Message{{Role: "user", Content: "Blocked prompt"}}, + }) + + if err == nil { + t.Error("expected error for blocked prompt") + } + if !strings.Contains(err.Error(), "blocked by Gemini") { + t.Errorf("expected 'blocked by Gemini' error, got %v", err) + } +} + +func TestGeminiClient_ListModels_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Errorf("expected GET, got %s", r.Method) + } + if !strings.Contains(r.URL.Path, "/models") { + t.Errorf("expected /models in path, got %s", r.URL.Path) + } + + resp := struct { + Models []struct { + Name string `json:"name"` + DisplayName string `json:"displayName"` + Description string `json:"description"` + SupportedGenerationMethods []string `json:"supportedGenerationMethods"` + } `json:"models"` + }{ + Models: []struct { + Name string `json:"name"` + DisplayName string `json:"displayName"` + Description string `json:"description"` + SupportedGenerationMethods []string `json:"supportedGenerationMethods"` + }{ + { + Name: "models/gemini-1.5-pro", + DisplayName: "Gemini 1.5 Pro", + Description: "Advanced language model", + SupportedGenerationMethods: []string{"generateContent"}, + }, + { + Name: "models/gemini-1.5-flash", + DisplayName: "Gemini 1.5 Flash", + Description: "Fast model", + SupportedGenerationMethods: []string{"generateContent"}, + }, + { + Name: "models/text-embedding-004", // Should be filtered + DisplayName: "Text Embedding", + Description: "Embedding model", + SupportedGenerationMethods: []string{"embedContent"}, + }, + }, + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewGeminiClient("test-key", "gemini-pro", server.URL) + + ctx := context.Background() + models, err := client.ListModels(ctx) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Should only include gemini-1.5-* models, not embedding + if len(models) != 2 { + t.Errorf("expected 2 models, got %d", len(models)) + } +} + +func TestGeminiClient_ListModels_Error(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("Internal Server Error")) + })) + defer server.Close() + + client := NewGeminiClient("test-key", "gemini-pro", server.URL) + + ctx := context.Background() + _, err := client.ListModels(ctx) + + if err == nil { + t.Error("expected error for server error") + } +} + +func TestGeminiClient_TestConnection(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := struct { + Models []interface{} `json:"models"` + }{Models: []interface{}{}} + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewGeminiClient("test-key", "gemini-pro", server.URL) + + ctx := context.Background() + err := client.TestConnection(ctx) + + if err != nil { + t.Errorf("unexpected error: %v", err) + } +} + +func TestGeminiClient_Chat_NetworkError(t *testing.T) { + client := NewGeminiClient("test-key", "gemini-pro", "http://localhost:99999") + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + _, err := client.Chat(ctx, ChatRequest{ + Messages: []Message{{Role: "user", Content: "Hello"}}, + }) + + if err == nil { + t.Error("expected error for network failure") + } +} + +func TestGeminiClient_Chat_RoleConversion(t *testing.T) { + var receivedReq geminiRequest + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewDecoder(r.Body).Decode(&receivedReq) + + resp := geminiResponse{ + Candidates: []geminiCandidate{ + { + Content: geminiContent{Parts: []geminiPart{{Text: "Ok"}}}, + FinishReason: "STOP", + }, + }, + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := NewGeminiClient("test-key", "gemini-pro", server.URL) + + ctx := context.Background() + _, err := client.Chat(ctx, ChatRequest{ + Messages: []Message{ + {Role: "user", Content: "Hello"}, + {Role: "assistant", Content: "Hi there"}, + {Role: "user", Content: "How are you?"}, + }, + }) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify role conversion: assistant -> model + if len(receivedReq.Contents) != 3 { + t.Fatalf("expected 3 contents, got %d", len(receivedReq.Contents)) + } + + if receivedReq.Contents[0].Role != "user" { + t.Errorf("expected first role 'user', got %q", receivedReq.Contents[0].Role) + } + if receivedReq.Contents[1].Role != "model" { + t.Errorf("expected second role 'model', got %q", receivedReq.Contents[1].Role) + } + if receivedReq.Contents[2].Role != "user" { + t.Errorf("expected third role 'user', got %q", receivedReq.Contents[2].Role) + } +} diff --git a/internal/api/ai_intelligence_handlers_test.go b/internal/api/ai_intelligence_handlers_test.go new file mode 100644 index 0000000..4253853 --- /dev/null +++ b/internal/api/ai_intelligence_handlers_test.go @@ -0,0 +1,343 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" +) + +// createTestAIHandler creates a test AI handler with minimal setup +func createTestAIHandler(t *testing.T) *AISettingsHandler { + t.Helper() + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + return NewAISettingsHandler(cfg, persistence, nil) +} + +// TestHandleGetPatterns tests the patterns endpoint +func TestHandleGetPatterns_MethodNotAllowed(t *testing.T) { + t.Parallel() + handler := createTestAIHandler(t) + + req := httptest.NewRequest(http.MethodPost, "/api/ai/intelligence/patterns", nil) + rec := httptest.NewRecorder() + + handler.HandleGetPatterns(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status %d for POST, got %d", http.StatusMethodNotAllowed, rec.Code) + } +} + +func TestHandleGetPatterns_NoPatrolService(t *testing.T) { + t.Parallel() + handler := createTestAIHandler(t) + + req := httptest.NewRequest(http.MethodGet, "/api/ai/intelligence/patterns", nil) + rec := httptest.NewRecorder() + + handler.HandleGetPatterns(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d: %s", http.StatusOK, rec.Code, rec.Body.String()) + } + + var resp map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + // Should return empty patterns array with message + patterns, ok := resp["patterns"].([]interface{}) + if !ok { + t.Fatal("expected patterns array in response") + } + if len(patterns) != 0 { + t.Fatalf("expected empty patterns, got %d", len(patterns)) + } + if resp["message"] != "Patrol service not initialized" { + t.Fatalf("unexpected message: %v", resp["message"]) + } +} + +func TestHandleGetPatterns_ResourceIDFilter(t *testing.T) { + t.Parallel() + handler := createTestAIHandler(t) + + // Test with resource_id query parameter - should still return gracefully + req := httptest.NewRequest(http.MethodGet, "/api/ai/intelligence/patterns?resource_id=vm-100", nil) + rec := httptest.NewRecorder() + + handler.HandleGetPatterns(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d: %s", http.StatusOK, rec.Code, rec.Body.String()) + } + + var resp map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + patterns, ok := resp["patterns"].([]interface{}) + if !ok { + t.Fatal("expected patterns array in response") + } + if len(patterns) != 0 { + t.Fatalf("expected empty patterns for non-initialized service, got %d", len(patterns)) + } +} + +// TestHandleGetPredictions tests the predictions endpoint +func TestHandleGetPredictions_MethodNotAllowed(t *testing.T) { + t.Parallel() + handler := createTestAIHandler(t) + + req := httptest.NewRequest(http.MethodPost, "/api/ai/intelligence/predictions", nil) + rec := httptest.NewRecorder() + + handler.HandleGetPredictions(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status %d for POST, got %d", http.StatusMethodNotAllowed, rec.Code) + } +} + +func TestHandleGetPredictions_NoPatrolService(t *testing.T) { + t.Parallel() + handler := createTestAIHandler(t) + + req := httptest.NewRequest(http.MethodGet, "/api/ai/intelligence/predictions", nil) + rec := httptest.NewRecorder() + + handler.HandleGetPredictions(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d: %s", http.StatusOK, rec.Code, rec.Body.String()) + } + + var resp map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + predictions, ok := resp["predictions"].([]interface{}) + if !ok { + t.Fatal("expected predictions array in response") + } + if len(predictions) != 0 { + t.Fatalf("expected empty predictions, got %d", len(predictions)) + } + if resp["message"] != "Patrol service not initialized" { + t.Fatalf("unexpected message: %v", resp["message"]) + } +} + +func TestHandleGetPredictions_ResourceIDFilter(t *testing.T) { + t.Parallel() + handler := createTestAIHandler(t) + + req := httptest.NewRequest(http.MethodGet, "/api/ai/intelligence/predictions?resource_id=vm-200", nil) + rec := httptest.NewRecorder() + + handler.HandleGetPredictions(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d: %s", http.StatusOK, rec.Code, rec.Body.String()) + } + + var resp map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + // Verify response is valid JSON with expected structure + if _, ok := resp["predictions"]; !ok { + t.Fatal("expected predictions field in response") + } +} + +// TestHandleGetCorrelations tests the correlations endpoint +func TestHandleGetCorrelations_MethodNotAllowed(t *testing.T) { + t.Parallel() + handler := createTestAIHandler(t) + + req := httptest.NewRequest(http.MethodPost, "/api/ai/intelligence/correlations", nil) + rec := httptest.NewRecorder() + + handler.HandleGetCorrelations(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status %d for POST, got %d", http.StatusMethodNotAllowed, rec.Code) + } +} + +func TestHandleGetCorrelations_NoPatrolService(t *testing.T) { + t.Parallel() + handler := createTestAIHandler(t) + + req := httptest.NewRequest(http.MethodGet, "/api/ai/intelligence/correlations", nil) + rec := httptest.NewRecorder() + + handler.HandleGetCorrelations(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d: %s", http.StatusOK, rec.Code, rec.Body.String()) + } + + var resp map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + correlations, ok := resp["correlations"].([]interface{}) + if !ok { + t.Fatal("expected correlations array in response") + } + if len(correlations) != 0 { + t.Fatalf("expected empty correlations, got %d", len(correlations)) + } + if resp["message"] != "Patrol service not initialized" { + t.Fatalf("unexpected message: %v", resp["message"]) + } +} + +func TestHandleGetCorrelations_ResourceIDFilter(t *testing.T) { + t.Parallel() + handler := createTestAIHandler(t) + + req := httptest.NewRequest(http.MethodGet, "/api/ai/intelligence/correlations?resource_id=storage-1", nil) + rec := httptest.NewRecorder() + + handler.HandleGetCorrelations(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d: %s", http.StatusOK, rec.Code, rec.Body.String()) + } + + var resp map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + if _, ok := resp["correlations"]; !ok { + t.Fatal("expected correlations field in response") + } +} + +// TestHandleGetRecentChanges tests the changes endpoint +func TestHandleGetRecentChanges_MethodNotAllowed(t *testing.T) { + t.Parallel() + handler := createTestAIHandler(t) + + req := httptest.NewRequest(http.MethodPost, "/api/ai/intelligence/changes", nil) + rec := httptest.NewRecorder() + + handler.HandleGetRecentChanges(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status %d for POST, got %d", http.StatusMethodNotAllowed, rec.Code) + } +} + +func TestHandleGetRecentChanges_NoPatrolService(t *testing.T) { + t.Parallel() + handler := createTestAIHandler(t) + + req := httptest.NewRequest(http.MethodGet, "/api/ai/intelligence/changes", nil) + rec := httptest.NewRecorder() + + handler.HandleGetRecentChanges(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d: %s", http.StatusOK, rec.Code, rec.Body.String()) + } + + var resp map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + changes, ok := resp["changes"].([]interface{}) + if !ok { + t.Fatal("expected changes array in response") + } + if len(changes) != 0 { + t.Fatalf("expected empty changes, got %d", len(changes)) + } + if resp["message"] != "Patrol service not initialized" { + t.Fatalf("unexpected message: %v", resp["message"]) + } +} + +// TestHandleGetBaselines tests the baselines endpoint +func TestHandleGetBaselines_MethodNotAllowed(t *testing.T) { + t.Parallel() + handler := createTestAIHandler(t) + + req := httptest.NewRequest(http.MethodPost, "/api/ai/intelligence/baselines", nil) + rec := httptest.NewRecorder() + + handler.HandleGetBaselines(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status %d for POST, got %d", http.StatusMethodNotAllowed, rec.Code) + } +} + +func TestHandleGetBaselines_NoPatrolService(t *testing.T) { + t.Parallel() + handler := createTestAIHandler(t) + + req := httptest.NewRequest(http.MethodGet, "/api/ai/intelligence/baselines", nil) + rec := httptest.NewRecorder() + + handler.HandleGetBaselines(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d: %s", http.StatusOK, rec.Code, rec.Body.String()) + } + + var resp map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + baselines, ok := resp["baselines"].([]interface{}) + if !ok { + t.Fatal("expected baselines array in response") + } + if len(baselines) != 0 { + t.Fatalf("expected empty baselines, got %d", len(baselines)) + } + if resp["message"] != "Patrol service not initialized" { + t.Fatalf("unexpected message: %v", resp["message"]) + } +} + +func TestHandleGetBaselines_ResourceIDFilter(t *testing.T) { + t.Parallel() + handler := createTestAIHandler(t) + + req := httptest.NewRequest(http.MethodGet, "/api/ai/intelligence/baselines?resource_id=node-1", nil) + rec := httptest.NewRecorder() + + handler.HandleGetBaselines(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d: %s", http.StatusOK, rec.Code, rec.Body.String()) + } + + var resp map[string]interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + if _, ok := resp["baselines"]; !ok { + t.Fatal("expected baselines field in response") + } +} diff --git a/internal/api/ai_patrol_handlers_test.go b/internal/api/ai_patrol_handlers_test.go new file mode 100644 index 0000000..6fbe5bf --- /dev/null +++ b/internal/api/ai_patrol_handlers_test.go @@ -0,0 +1,298 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" +) + +func TestHandleGetPatrolStatus_MethodNotAllowed(t *testing.T) { + t.Parallel() + handler := createTestAIHandler(t) + + req := httptest.NewRequest(http.MethodPost, "/api/ai/patrol/status", nil) + rec := httptest.NewRecorder() + + handler.HandleGetPatrolStatus(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status %d for POST, got %d", http.StatusMethodNotAllowed, rec.Code) + } +} + +func TestHandleGetPatrolStatus_NoPatrolService(t *testing.T) { + t.Parallel() + handler := createTestAIHandler(t) + + req := httptest.NewRequest(http.MethodGet, "/api/ai/patrol/status", nil) + rec := httptest.NewRecorder() + + handler.HandleGetPatrolStatus(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d: %s", http.StatusOK, rec.Code, rec.Body.String()) + } + + var resp PatrolStatusResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + // When patrol service is not initialized, should return safe defaults + if resp.Running { + t.Error("expected Running to be false when patrol not initialized") + } + if resp.Enabled { + t.Error("expected Enabled to be false when patrol not initialized") + } + if !resp.Healthy { + t.Error("expected Healthy to be true when patrol not initialized (no issues)") + } +} + +func TestHandleGetPatrolFindings_MethodNotAllowed(t *testing.T) { + t.Parallel() + handler := createTestAIHandler(t) + + req := httptest.NewRequest(http.MethodPost, "/api/ai/patrol/findings", nil) + rec := httptest.NewRecorder() + + handler.HandleGetPatrolFindings(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status %d for POST, got %d", http.StatusMethodNotAllowed, rec.Code) + } +} + +func TestHandleGetPatrolFindings_NoPatrolService(t *testing.T) { + t.Parallel() + handler := createTestAIHandler(t) + + req := httptest.NewRequest(http.MethodGet, "/api/ai/patrol/findings", nil) + rec := httptest.NewRecorder() + + handler.HandleGetPatrolFindings(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d: %s", http.StatusOK, rec.Code, rec.Body.String()) + } + + // Should return empty array when patrol not initialized + var findings []interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &findings); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + if len(findings) != 0 { + t.Errorf("expected empty findings, got %d", len(findings)) + } +} + +func TestHandleGetPatrolFindings_WithResourceIDFilter(t *testing.T) { + t.Parallel() + handler := createTestAIHandler(t) + + req := httptest.NewRequest(http.MethodGet, "/api/ai/patrol/findings?resource_id=vm-100", nil) + rec := httptest.NewRecorder() + + handler.HandleGetPatrolFindings(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d: %s", http.StatusOK, rec.Code, rec.Body.String()) + } +} + +func TestHandleForcePatrol_MethodNotAllowed(t *testing.T) { + t.Parallel() + handler := createTestAIHandler(t) + + req := httptest.NewRequest(http.MethodGet, "/api/ai/patrol/run", nil) + rec := httptest.NewRecorder() + + handler.HandleForcePatrol(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status %d for GET, got %d", http.StatusMethodNotAllowed, rec.Code) + } +} + +func TestHandleAcknowledgeFinding_MethodNotAllowed(t *testing.T) { + t.Parallel() + handler := createTestAIHandler(t) + + req := httptest.NewRequest(http.MethodGet, "/api/ai/patrol/acknowledge", nil) + rec := httptest.NewRecorder() + + handler.HandleAcknowledgeFinding(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status %d for GET, got %d", http.StatusMethodNotAllowed, rec.Code) + } +} + +func TestHandleSnoozeFinding_MethodNotAllowed(t *testing.T) { + t.Parallel() + handler := createTestAIHandler(t) + + req := httptest.NewRequest(http.MethodGet, "/api/ai/patrol/snooze", nil) + rec := httptest.NewRecorder() + + handler.HandleSnoozeFinding(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status %d for GET, got %d", http.StatusMethodNotAllowed, rec.Code) + } +} + +func TestHandleResolveFinding_MethodNotAllowed(t *testing.T) { + t.Parallel() + handler := createTestAIHandler(t) + + req := httptest.NewRequest(http.MethodGet, "/api/ai/patrol/resolve", nil) + rec := httptest.NewRecorder() + + handler.HandleResolveFinding(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status %d for GET, got %d", http.StatusMethodNotAllowed, rec.Code) + } +} + +func TestHandleDismissFinding_MethodNotAllowed(t *testing.T) { + t.Parallel() + handler := createTestAIHandler(t) + + req := httptest.NewRequest(http.MethodGet, "/api/ai/patrol/dismiss", nil) + rec := httptest.NewRecorder() + + handler.HandleDismissFinding(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status %d for GET, got %d", http.StatusMethodNotAllowed, rec.Code) + } +} + +func TestHandleDismissFinding_InvalidReason(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + + // Set up auth - needed for authenticated handlers + InitSessionStore(tmp) + + handler := NewAISettingsHandler(cfg, persistence, nil) + + // Invalid dismiss reason + body, _ := json.Marshal(map[string]string{ + "finding_id": "test-finding", + "reason": "invalid_reason_value", + }) + + req := httptest.NewRequest(http.MethodPost, "/api/ai/patrol/dismiss", bytes.NewReader(body)) + rec := httptest.NewRecorder() + + handler.HandleDismissFinding(rec, req) + + // Should fail because patrol service not available (after auth check) + // But more importantly, validates the request handling path + if rec.Code == http.StatusOK { + t.Error("expected non-OK status for missing patrol service or invalid input") + } +} + +func TestHandleSnoozeFinding_DurationValidation(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + cfg := &config.Config{DataPath: tmp} + persistence := config.NewConfigPersistence(tmp) + + InitSessionStore(tmp) + + handler := NewAISettingsHandler(cfg, persistence, nil) + + // Missing finding_id + body, _ := json.Marshal(map[string]interface{}{ + "duration_hours": 24, + }) + + req := httptest.NewRequest(http.MethodPost, "/api/ai/patrol/snooze", bytes.NewReader(body)) + rec := httptest.NewRecorder() + + handler.HandleSnoozeFinding(rec, req) + + // Should fail auth or patrol checks + if rec.Code == http.StatusOK { + t.Error("expected non-OK status") + } +} + +func TestHandleGetFindingsHistory_MethodNotAllowed(t *testing.T) { + t.Parallel() + handler := createTestAIHandler(t) + + req := httptest.NewRequest(http.MethodPost, "/api/ai/patrol/history", nil) + rec := httptest.NewRecorder() + + handler.HandleGetFindingsHistory(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status %d for POST, got %d", http.StatusMethodNotAllowed, rec.Code) + } +} + +func TestHandleGetPatrolRunHistory_MethodNotAllowed(t *testing.T) { + t.Parallel() + handler := createTestAIHandler(t) + + req := httptest.NewRequest(http.MethodPost, "/api/ai/patrol/runs", nil) + rec := httptest.NewRecorder() + + handler.HandleGetPatrolRunHistory(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status %d for POST, got %d", http.StatusMethodNotAllowed, rec.Code) + } +} + +func TestHandleGetPatrolRunHistory_NoPatrolService(t *testing.T) { + t.Parallel() + handler := createTestAIHandler(t) + + req := httptest.NewRequest(http.MethodGet, "/api/ai/patrol/runs", nil) + rec := httptest.NewRecorder() + + handler.HandleGetPatrolRunHistory(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d: %s", http.StatusOK, rec.Code, rec.Body.String()) + } + + // Should return empty array + var history []interface{} + if err := json.Unmarshal(rec.Body.Bytes(), &history); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + if len(history) != 0 { + t.Errorf("expected empty history, got %d", len(history)) + } +} + +func TestHandleSuppressFinding_MethodNotAllowed(t *testing.T) { + t.Parallel() + handler := createTestAIHandler(t) + + req := httptest.NewRequest(http.MethodGet, "/api/ai/patrol/suppress", nil) + rec := httptest.NewRecorder() + + handler.HandleSuppressFinding(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status %d for GET, got %d", http.StatusMethodNotAllowed, rec.Code) + } +} diff --git a/internal/api/license_handlers_test.go b/internal/api/license_handlers_test.go new file mode 100644 index 0000000..9a74257 --- /dev/null +++ b/internal/api/license_handlers_test.go @@ -0,0 +1,114 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/license" +) + +type licenseFeaturesResponse struct { + LicenseStatus string `json:"license_status"` + Features map[string]bool `json:"features"` + UpgradeURL string `json:"upgrade_url"` +} + +func TestHandleLicenseFeatures_MethodNotAllowed(t *testing.T) { + handler := NewLicenseHandlers(t.TempDir()) + + req := httptest.NewRequest(http.MethodPost, "/api/license/features", nil) + rec := httptest.NewRecorder() + + handler.HandleLicenseFeatures(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code) + } +} + +func TestHandleLicenseFeatures_NoLicense(t *testing.T) { + handler := NewLicenseHandlers(t.TempDir()) + + req := httptest.NewRequest(http.MethodGet, "/api/license/features", nil) + rec := httptest.NewRecorder() + + handler.HandleLicenseFeatures(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) + } + + var resp licenseFeaturesResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + if resp.LicenseStatus != string(license.LicenseStateNone) { + t.Fatalf("expected license_status %q, got %q", license.LicenseStateNone, resp.LicenseStatus) + } + if resp.UpgradeURL == "" { + t.Fatalf("expected upgrade_url to be set") + } + + expectedFeatures := []string{ + license.FeatureAIPatrol, + license.FeatureAIAlerts, + license.FeatureAIAutoFix, + license.FeatureKubernetesAI, + } + for _, feature := range expectedFeatures { + if value, ok := resp.Features[feature]; !ok { + t.Fatalf("expected feature %q in response", feature) + } else if value { + t.Fatalf("expected feature %q to be false without a license", feature) + } + } +} + +func TestHandleLicenseFeatures_WithActiveLicense(t *testing.T) { + t.Setenv("PULSE_LICENSE_DEV_MODE", "true") + + handler := NewLicenseHandlers(t.TempDir()) + licenseKey, err := license.GenerateLicenseForTesting("test@example.com", license.TierPro, 24*time.Hour) + if err != nil { + t.Fatalf("failed to generate test license: %v", err) + } + if _, err := handler.Service().Activate(licenseKey); err != nil { + t.Fatalf("failed to activate test license: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/license/features", nil) + rec := httptest.NewRecorder() + + handler.HandleLicenseFeatures(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) + } + + var resp licenseFeaturesResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + if resp.LicenseStatus != string(license.LicenseStateActive) { + t.Fatalf("expected license_status %q, got %q", license.LicenseStateActive, resp.LicenseStatus) + } + + expectedFeatures := []string{ + license.FeatureAIPatrol, + license.FeatureAIAlerts, + license.FeatureAIAutoFix, + license.FeatureKubernetesAI, + } + for _, feature := range expectedFeatures { + if value, ok := resp.Features[feature]; !ok { + t.Fatalf("expected feature %q in response", feature) + } else if !value { + t.Fatalf("expected feature %q to be true with active license", feature) + } + } +} diff --git a/internal/api/security_oidc_response_test.go b/internal/api/security_oidc_response_test.go new file mode 100644 index 0000000..b1a715d --- /dev/null +++ b/internal/api/security_oidc_response_test.go @@ -0,0 +1,182 @@ +package api + +import ( + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" +) + +func TestMakeOIDCResponse_NilConfig(t *testing.T) { + t.Parallel() + + resp := makeOIDCResponse(nil, "https://pulse.example.com") + + // Should return a default config when nil is passed + if resp.Enabled { + t.Error("expected Enabled to be false for nil config") + } + + // Should have appropriate defaults + if resp.DefaultRedirect == "" { + t.Error("expected DefaultRedirect to be set") + } +} + +func TestMakeOIDCResponse_EmptyConfig(t *testing.T) { + t.Parallel() + + cfg := config.NewOIDCConfig() + resp := makeOIDCResponse(cfg, "https://pulse.example.com") + + if resp.Enabled { + t.Error("expected Enabled to be false for new config") + } + if resp.ClientSecretSet { + t.Error("expected ClientSecretSet to be false when no secret") + } +} + +func TestMakeOIDCResponse_EnabledWithSecret(t *testing.T) { + t.Parallel() + + cfg := &config.OIDCConfig{ + Enabled: true, + IssuerURL: "https://auth.example.com", + ClientID: "pulse-client", + ClientSecret: "super-secret-value", + RedirectURL: "https://pulse.example.com/auth/callback", + Scopes: []string{"openid", "profile", "email"}, + UsernameClaim: "preferred_username", + EmailClaim: "email", + GroupsClaim: "groups", + } + + resp := makeOIDCResponse(cfg, "https://pulse.example.com") + + if !resp.Enabled { + t.Error("expected Enabled to be true") + } + if resp.IssuerURL != "https://auth.example.com" { + t.Errorf("expected IssuerURL 'https://auth.example.com', got %q", resp.IssuerURL) + } + if resp.ClientID != "pulse-client" { + t.Errorf("expected ClientID 'pulse-client', got %q", resp.ClientID) + } + if !resp.ClientSecretSet { + t.Error("expected ClientSecretSet to be true when secret is set") + } + // Client secret should NOT be exposed in response + // (this is handled by the response struct not having a ClientSecret field) + + if len(resp.Scopes) != 3 { + t.Errorf("expected 3 scopes, got %d", len(resp.Scopes)) + } + if resp.UsernameClaim != "preferred_username" { + t.Errorf("expected UsernameClaim 'preferred_username', got %q", resp.UsernameClaim) + } + if resp.EmailClaim != "email" { + t.Errorf("expected EmailClaim 'email', got %q", resp.EmailClaim) + } +} + +func TestMakeOIDCResponse_WithEnvOverrides(t *testing.T) { + t.Parallel() + + cfg := &config.OIDCConfig{ + Enabled: true, + IssuerURL: "https://auth.example.com", + ClientID: "env-client", + EnvOverrides: map[string]bool{ + "OIDC_ENABLED": true, + "OIDC_ISSUER_URL": true, + "OIDC_CLIENT_ID": true, + }, + } + + resp := makeOIDCResponse(cfg, "https://pulse.example.com") + + if resp.EnvOverrides == nil { + t.Fatal("expected EnvOverrides to be set") + } + if len(resp.EnvOverrides) != 3 { + t.Errorf("expected 3 env overrides, got %d", len(resp.EnvOverrides)) + } + if !resp.EnvOverrides["OIDC_ENABLED"] { + t.Error("expected OIDC_ENABLED override to be true") + } + if !resp.EnvOverrides["OIDC_ISSUER_URL"] { + t.Error("expected OIDC_ISSUER_URL override to be true") + } +} + +func TestMakeOIDCResponse_AllowedFilters(t *testing.T) { + t.Parallel() + + cfg := &config.OIDCConfig{ + Enabled: true, + IssuerURL: "https://auth.example.com", + ClientID: "pulse-client", + AllowedGroups: []string{"admins", "ops"}, + AllowedDomains: []string{"example.com", "corp.example.com"}, + AllowedEmails: []string{"admin@example.com"}, + } + + resp := makeOIDCResponse(cfg, "https://pulse.example.com") + + if len(resp.AllowedGroups) != 2 { + t.Errorf("expected 2 allowed groups, got %d", len(resp.AllowedGroups)) + } + if len(resp.AllowedDomains) != 2 { + t.Errorf("expected 2 allowed domains, got %d", len(resp.AllowedDomains)) + } + if len(resp.AllowedEmails) != 1 { + t.Errorf("expected 1 allowed email, got %d", len(resp.AllowedEmails)) + } +} + +func TestMakeOIDCResponse_CABundle(t *testing.T) { + t.Parallel() + + cfg := &config.OIDCConfig{ + Enabled: true, + IssuerURL: "https://auth.internal.example.com", + ClientID: "pulse-client", + CABundle: "-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----", + } + + resp := makeOIDCResponse(cfg, "https://pulse.example.com") + + if resp.CABundle == "" { + t.Error("expected CABundle to be set") + } + if resp.CABundle != cfg.CABundle { + t.Error("expected CABundle to match input") + } +} + +func TestMakeOIDCResponse_SlicesCopied(t *testing.T) { + t.Parallel() + + // Ensure that slices are copied, not shared + cfg := &config.OIDCConfig{ + Enabled: true, + IssuerURL: "https://auth.example.com", + ClientID: "pulse-client", + Scopes: []string{"openid", "profile"}, + AllowedGroups: []string{"admins"}, + } + + resp := makeOIDCResponse(cfg, "https://pulse.example.com") + + // Modify the original slices + cfg.Scopes = append(cfg.Scopes, "email") + cfg.AllowedGroups[0] = "modified" + + // Response should not be affected + if len(resp.Scopes) != 2 { + t.Errorf("expected response scopes to be unchanged, got %d", len(resp.Scopes)) + } + if resp.AllowedGroups[0] == "modified" { + t.Error("response AllowedGroups should be a copy, not a reference") + } +} diff --git a/internal/api/updates_test.go b/internal/api/updates_test.go index 70d842f..e35e381 100644 --- a/internal/api/updates_test.go +++ b/internal/api/updates_test.go @@ -2,9 +2,246 @@ package api import ( "net/http" + "net/http/httptest" + "strings" "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/updates" ) +func TestUpdateHandlers_HandleCheckUpdates_MethodNotAllowed(t *testing.T) { + t.Parallel() + + handlers := &UpdateHandlers{} + + req := httptest.NewRequest(http.MethodPost, "/api/updates/check", nil) + rec := httptest.NewRecorder() + + handlers.HandleCheckUpdates(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code) + } +} + +func TestUpdateHandlers_HandleApplyUpdate_MethodNotAllowed(t *testing.T) { + t.Parallel() + + handlers := &UpdateHandlers{} + + req := httptest.NewRequest(http.MethodGet, "/api/updates/apply", nil) + rec := httptest.NewRecorder() + + handlers.HandleApplyUpdate(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code) + } +} + +func TestUpdateHandlers_HandleApplyUpdate_InvalidJSONBody(t *testing.T) { + t.Parallel() + + handlers := &UpdateHandlers{} + + req := httptest.NewRequest(http.MethodPost, "/api/updates/apply", strings.NewReader("invalid json")) + rec := httptest.NewRecorder() + + handlers.HandleApplyUpdate(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("expected status %d, got %d", http.StatusBadRequest, rec.Code) + } +} + +func TestUpdateHandlers_HandleApplyUpdate_MissingDownloadURL(t *testing.T) { + t.Parallel() + + handlers := &UpdateHandlers{} + + req := httptest.NewRequest(http.MethodPost, "/api/updates/apply", strings.NewReader(`{}`)) + rec := httptest.NewRecorder() + + handlers.HandleApplyUpdate(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("expected status %d, got %d", http.StatusBadRequest, rec.Code) + } +} + +func TestUpdateHandlers_HandleUpdateStatus_MethodNotAllowed(t *testing.T) { + t.Parallel() + + handlers := &UpdateHandlers{} + + req := httptest.NewRequest(http.MethodPost, "/api/updates/status", nil) + rec := httptest.NewRecorder() + + handlers.HandleUpdateStatus(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code) + } +} + +func TestUpdateHandlers_HandleUpdateStream_MethodNotAllowed(t *testing.T) { + t.Parallel() + + handlers := &UpdateHandlers{} + + req := httptest.NewRequest(http.MethodPost, "/api/updates/stream", nil) + rec := httptest.NewRecorder() + + handlers.HandleUpdateStream(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code) + } +} + +func TestUpdateHandlers_HandleGetUpdatePlan_MethodNotAllowed(t *testing.T) { + t.Parallel() + + handlers := &UpdateHandlers{} + + req := httptest.NewRequest(http.MethodPost, "/api/updates/plan", nil) + rec := httptest.NewRecorder() + + handlers.HandleGetUpdatePlan(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code) + } +} + +func TestUpdateHandlers_HandleListUpdateHistory_MethodNotAllowed(t *testing.T) { + t.Parallel() + + handlers := &UpdateHandlers{} + + req := httptest.NewRequest(http.MethodPost, "/api/updates/history", nil) + rec := httptest.NewRecorder() + + handlers.HandleListUpdateHistory(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code) + } +} + +func TestUpdateHandlers_HandleListUpdateHistory_NoHistory(t *testing.T) { + t.Parallel() + + handlers := &UpdateHandlers{ + history: nil, + } + + req := httptest.NewRequest(http.MethodGet, "/api/updates/history", nil) + rec := httptest.NewRecorder() + + handlers.HandleListUpdateHistory(rec, req) + + if rec.Code != http.StatusServiceUnavailable { + t.Errorf("expected status %d, got %d", http.StatusServiceUnavailable, rec.Code) + } +} + +func TestUpdateHandlers_HandleListUpdateHistory_Success(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + history, _ := updates.NewUpdateHistory(tmp) + + handlers := &UpdateHandlers{ + history: history, + } + + req := httptest.NewRequest(http.MethodGet, "/api/updates/history", nil) + rec := httptest.NewRecorder() + + handlers.HandleListUpdateHistory(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("expected status %d, got %d", http.StatusOK, rec.Code) + } + + if ct := rec.Header().Get("Content-Type"); ct != "application/json" { + t.Errorf("expected content-type application/json, got %q", ct) + } +} + +func TestUpdateHandlers_HandleGetUpdateHistoryEntry_MethodNotAllowed(t *testing.T) { + t.Parallel() + + handlers := &UpdateHandlers{} + + req := httptest.NewRequest(http.MethodPost, "/api/updates/history/123", nil) + rec := httptest.NewRecorder() + + handlers.HandleGetUpdateHistoryEntry(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code) + } +} + +func TestUpdateHandlers_HandleGetUpdateHistoryEntry_NoHistory(t *testing.T) { + t.Parallel() + + handlers := &UpdateHandlers{ + history: nil, + } + + req := httptest.NewRequest(http.MethodGet, "/api/updates/history/123", nil) + rec := httptest.NewRecorder() + + handlers.HandleGetUpdateHistoryEntry(rec, req) + + if rec.Code != http.StatusServiceUnavailable { + t.Errorf("expected status %d, got %d", http.StatusServiceUnavailable, rec.Code) + } +} + +func TestUpdateHandlers_HandleGetUpdateHistoryEntry_MissingID(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + history, _ := updates.NewUpdateHistory(tmp) + + handlers := &UpdateHandlers{ + history: history, + } + + req := httptest.NewRequest(http.MethodGet, "/api/updates/history/entry", nil) + rec := httptest.NewRecorder() + + handlers.HandleGetUpdateHistoryEntry(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Errorf("expected status %d, got %d", http.StatusBadRequest, rec.Code) + } +} + +func TestUpdateHandlers_HandleGetUpdateHistoryEntry_NotFound(t *testing.T) { + t.Parallel() + + tmp := t.TempDir() + history, _ := updates.NewUpdateHistory(tmp) + + handlers := &UpdateHandlers{ + history: history, + } + + req := httptest.NewRequest(http.MethodGet, "/api/updates/history/entry?id=nonexistent", nil) + rec := httptest.NewRecorder() + + handlers.HandleGetUpdateHistoryEntry(rec, req) + + if rec.Code != http.StatusNotFound { + t.Errorf("expected status %d, got %d", http.StatusNotFound, rec.Code) + } +} + func TestGetClientIP(t *testing.T) { tests := []struct { name string diff --git a/internal/config/ai_config_test.go b/internal/config/ai_config_test.go new file mode 100644 index 0000000..5fc5f66 --- /dev/null +++ b/internal/config/ai_config_test.go @@ -0,0 +1,677 @@ +package config + +import ( + "testing" + "time" +) + +func TestAIConfig_IsConfigured(t *testing.T) { + tests := []struct { + name string + config AIConfig + expected bool + }{ + { + name: "disabled config", + config: AIConfig{Enabled: false}, + expected: false, + }, + { + name: "enabled with anthropic key", + config: AIConfig{ + Enabled: true, + AnthropicAPIKey: "sk-ant-123", + }, + expected: true, + }, + { + name: "enabled with openai key", + config: AIConfig{ + Enabled: true, + OpenAIAPIKey: "sk-openai-123", + }, + expected: true, + }, + { + name: "enabled with gemini key", + config: AIConfig{ + Enabled: true, + GeminiAPIKey: "gemini-123", + }, + expected: true, + }, + { + name: "enabled with ollama url", + config: AIConfig{ + Enabled: true, + OllamaBaseURL: "http://localhost:11434", + }, + expected: true, + }, + { + name: "enabled with oauth", + config: AIConfig{ + Enabled: true, + AuthMethod: AuthMethodOAuth, + OAuthAccessToken: "oauth-token", + }, + expected: true, + }, + { + name: "enabled but no credentials", + config: AIConfig{ + Enabled: true, + }, + expected: false, + }, + { + name: "legacy provider with api key", + config: AIConfig{ + Enabled: true, + Provider: AIProviderAnthropic, + APIKey: "legacy-key", + }, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.config.IsConfigured() + if result != tt.expected { + t.Errorf("IsConfigured() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestAIConfig_HasProvider(t *testing.T) { + tests := []struct { + name string + config AIConfig + provider string + expected bool + }{ + { + name: "no anthropic configured", + config: AIConfig{}, + provider: AIProviderAnthropic, + expected: false, + }, + { + name: "anthropic with api key", + config: AIConfig{AnthropicAPIKey: "key"}, + provider: AIProviderAnthropic, + expected: true, + }, + { + name: "anthropic with oauth", + config: AIConfig{ + AuthMethod: AuthMethodOAuth, + OAuthAccessToken: "token", + }, + provider: AIProviderAnthropic, + expected: true, + }, + { + name: "openai configured", + config: AIConfig{OpenAIAPIKey: "key"}, + provider: AIProviderOpenAI, + expected: true, + }, + { + name: "deepseek configured", + config: AIConfig{DeepSeekAPIKey: "key"}, + provider: AIProviderDeepSeek, + expected: true, + }, + { + name: "gemini configured", + config: AIConfig{GeminiAPIKey: "key"}, + provider: AIProviderGemini, + expected: true, + }, + { + name: "ollama configured", + config: AIConfig{OllamaBaseURL: "http://localhost:11434"}, + provider: AIProviderOllama, + expected: true, + }, + { + name: "unknown provider", + config: AIConfig{}, + provider: "unknown", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.config.HasProvider(tt.provider) + if result != tt.expected { + t.Errorf("HasProvider(%q) = %v, want %v", tt.provider, result, tt.expected) + } + }) + } +} + +func TestAIConfig_GetConfiguredProviders(t *testing.T) { + tests := []struct { + name string + config AIConfig + count int + }{ + { + name: "no providers", + config: AIConfig{}, + count: 0, + }, + { + name: "one provider", + config: AIConfig{ + AnthropicAPIKey: "key", + }, + count: 1, + }, + { + name: "multiple providers", + config: AIConfig{ + AnthropicAPIKey: "key1", + OpenAIAPIKey: "key2", + GeminiAPIKey: "key3", + }, + count: 3, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + providers := tt.config.GetConfiguredProviders() + if len(providers) != tt.count { + t.Errorf("GetConfiguredProviders() returned %d providers, want %d", len(providers), tt.count) + } + }) + } +} + +func TestAIConfig_GetAPIKeyForProvider(t *testing.T) { + config := AIConfig{ + AnthropicAPIKey: "anthropic-key", + OpenAIAPIKey: "openai-key", + DeepSeekAPIKey: "deepseek-key", + GeminiAPIKey: "gemini-key", + } + + tests := []struct { + provider string + expected string + }{ + {AIProviderAnthropic, "anthropic-key"}, + {AIProviderOpenAI, "openai-key"}, + {AIProviderDeepSeek, "deepseek-key"}, + {AIProviderGemini, "gemini-key"}, + {AIProviderOllama, ""}, + {"unknown", ""}, + } + + for _, tt := range tests { + t.Run(tt.provider, func(t *testing.T) { + result := config.GetAPIKeyForProvider(tt.provider) + if result != tt.expected { + t.Errorf("GetAPIKeyForProvider(%q) = %q, want %q", tt.provider, result, tt.expected) + } + }) + } +} + +func TestAIConfig_GetBaseURLForProvider(t *testing.T) { + config := AIConfig{ + OllamaBaseURL: "http://custom:11434", + OpenAIBaseURL: "https://custom-openai.com", + } + + tests := []struct { + provider string + expected string + }{ + {AIProviderOllama, "http://custom:11434"}, + {AIProviderOpenAI, "https://custom-openai.com"}, + {AIProviderDeepSeek, DefaultDeepSeekBaseURL}, + {AIProviderGemini, DefaultGeminiBaseURL}, + {"unknown", ""}, + } + + for _, tt := range tests { + t.Run(tt.provider, func(t *testing.T) { + result := config.GetBaseURLForProvider(tt.provider) + if result != tt.expected { + t.Errorf("GetBaseURLForProvider(%q) = %q, want %q", tt.provider, result, tt.expected) + } + }) + } +} + +func TestAIConfig_IsUsingOAuth(t *testing.T) { + tests := []struct { + name string + config AIConfig + expected bool + }{ + { + name: "not using oauth", + config: AIConfig{AuthMethod: AuthMethodAPIKey}, + expected: false, + }, + { + name: "oauth method but no token", + config: AIConfig{ + AuthMethod: AuthMethodOAuth, + }, + expected: false, + }, + { + name: "using oauth with token", + config: AIConfig{ + AuthMethod: AuthMethodOAuth, + OAuthAccessToken: "token", + }, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.config.IsUsingOAuth() + if result != tt.expected { + t.Errorf("IsUsingOAuth() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestParseModelString(t *testing.T) { + tests := []struct { + model string + wantProvider string + wantModel string + }{ + // Explicit prefixes + {"anthropic:claude-3-opus", AIProviderAnthropic, "claude-3-opus"}, + {"openai:gpt-4o", AIProviderOpenAI, "gpt-4o"}, + {"ollama:llama3", AIProviderOllama, "llama3"}, + {"deepseek:deepseek-chat", AIProviderDeepSeek, "deepseek-chat"}, + {"gemini:gemini-1.5-pro", AIProviderGemini, "gemini-1.5-pro"}, + // Detection by name + {"claude-3-opus", AIProviderAnthropic, "claude-3-opus"}, + {"gpt-4o", AIProviderOpenAI, "gpt-4o"}, + {"o1-preview", AIProviderOpenAI, "o1-preview"}, + {"deepseek-chat", AIProviderDeepSeek, "deepseek-chat"}, + {"gemini-1.5-pro", AIProviderGemini, "gemini-1.5-pro"}, + // Unknown models default to Ollama + {"llama3", AIProviderOllama, "llama3"}, + {"mistral", AIProviderOllama, "mistral"}, + } + + for _, tt := range tests { + t.Run(tt.model, func(t *testing.T) { + provider, model := ParseModelString(tt.model) + if provider != tt.wantProvider { + t.Errorf("ParseModelString(%q) provider = %q, want %q", tt.model, provider, tt.wantProvider) + } + if model != tt.wantModel { + t.Errorf("ParseModelString(%q) model = %q, want %q", tt.model, model, tt.wantModel) + } + }) + } +} + +func TestFormatModelString(t *testing.T) { + result := FormatModelString(AIProviderAnthropic, "claude-3-opus") + if result != "anthropic:claude-3-opus" { + t.Errorf("FormatModelString() = %q, want %q", result, "anthropic:claude-3-opus") + } +} + +func TestAIConfig_GetBaseURL(t *testing.T) { + tests := []struct { + name string + config AIConfig + expected string + }{ + { + name: "custom base url", + config: AIConfig{ + BaseURL: "https://custom.url", + }, + expected: "https://custom.url", + }, + { + name: "ollama default", + config: AIConfig{ + Provider: AIProviderOllama, + }, + expected: DefaultOllamaBaseURL, + }, + { + name: "deepseek default", + config: AIConfig{ + Provider: AIProviderDeepSeek, + }, + expected: DefaultDeepSeekBaseURL, + }, + { + name: "gemini default", + config: AIConfig{ + Provider: AIProviderGemini, + }, + expected: DefaultGeminiBaseURL, + }, + { + name: "anthropic no URL", + config: AIConfig{ + Provider: AIProviderAnthropic, + }, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.config.GetBaseURL() + if result != tt.expected { + t.Errorf("GetBaseURL() = %q, want %q", result, tt.expected) + } + }) + } +} + +func TestAIConfig_GetModel(t *testing.T) { + tests := []struct { + name string + config AIConfig + expected string + }{ + { + name: "explicit model set", + config: AIConfig{Model: "custom-model"}, + expected: "custom-model", + }, + { + name: "single provider configured", + config: AIConfig{ + AnthropicAPIKey: "key", + }, + expected: DefaultAIModelAnthropic, + }, + { + name: "legacy provider fallback", + config: AIConfig{ + Provider: AIProviderOpenAI, + }, + expected: DefaultAIModelOpenAI, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.config.GetModel() + if result != tt.expected { + t.Errorf("GetModel() = %q, want %q", result, tt.expected) + } + }) + } +} + +func TestAIConfig_GetChatModel(t *testing.T) { + t.Run("explicit chat model", func(t *testing.T) { + config := AIConfig{ + Model: "default-model", + ChatModel: "chat-model", + } + if result := config.GetChatModel(); result != "chat-model" { + t.Errorf("GetChatModel() = %q, want 'chat-model'", result) + } + }) + + t.Run("fallback to main model", func(t *testing.T) { + config := AIConfig{ + Model: "main-model", + } + if result := config.GetChatModel(); result != "main-model" { + t.Errorf("GetChatModel() = %q, want 'main-model'", result) + } + }) +} + +func TestAIConfig_GetPatrolModel(t *testing.T) { + t.Run("explicit patrol model", func(t *testing.T) { + config := AIConfig{ + Model: "default-model", + PatrolModel: "patrol-model", + } + if result := config.GetPatrolModel(); result != "patrol-model" { + t.Errorf("GetPatrolModel() = %q, want 'patrol-model'", result) + } + }) + + t.Run("fallback to main model", func(t *testing.T) { + config := AIConfig{ + Model: "main-model", + } + if result := config.GetPatrolModel(); result != "main-model" { + t.Errorf("GetPatrolModel() = %q, want 'main-model'", result) + } + }) +} + +func TestAIConfig_GetAutoFixModel(t *testing.T) { + t.Run("explicit autofix model", func(t *testing.T) { + config := AIConfig{ + AutoFixModel: "autofix-model", + } + if result := config.GetAutoFixModel(); result != "autofix-model" { + t.Errorf("GetAutoFixModel() = %q, want 'autofix-model'", result) + } + }) + + t.Run("fallback to patrol model", func(t *testing.T) { + config := AIConfig{ + PatrolModel: "patrol-model", + } + if result := config.GetAutoFixModel(); result != "patrol-model" { + t.Errorf("GetAutoFixModel() = %q, want 'patrol-model'", result) + } + }) +} + +func TestAIConfig_ClearOAuthTokens(t *testing.T) { + config := AIConfig{ + OAuthAccessToken: "access", + OAuthRefreshToken: "refresh", + OAuthExpiresAt: time.Now(), + } + + config.ClearOAuthTokens() + + if config.OAuthAccessToken != "" { + t.Error("OAuthAccessToken should be cleared") + } + if config.OAuthRefreshToken != "" { + t.Error("OAuthRefreshToken should be cleared") + } + if !config.OAuthExpiresAt.IsZero() { + t.Error("OAuthExpiresAt should be zero") + } +} + +func TestAIConfig_ClearAPIKey(t *testing.T) { + config := AIConfig{APIKey: "key"} + config.ClearAPIKey() + if config.APIKey != "" { + t.Error("APIKey should be cleared") + } +} + +func TestAIConfig_GetPatrolInterval(t *testing.T) { + tests := []struct { + name string + config AIConfig + expected time.Duration + }{ + { + name: "15min preset", + config: AIConfig{PatrolSchedulePreset: "15min"}, + expected: 15 * time.Minute, + }, + { + name: "1hr preset", + config: AIConfig{PatrolSchedulePreset: "1hr"}, + expected: 1 * time.Hour, + }, + { + name: "6hr preset", + config: AIConfig{PatrolSchedulePreset: "6hr"}, + expected: 6 * time.Hour, + }, + { + name: "12hr preset", + config: AIConfig{PatrolSchedulePreset: "12hr"}, + expected: 12 * time.Hour, + }, + { + name: "daily preset", + config: AIConfig{PatrolSchedulePreset: "daily"}, + expected: 24 * time.Hour, + }, + { + name: "disabled preset", + config: AIConfig{PatrolSchedulePreset: "disabled"}, + expected: 0, + }, + { + name: "custom minutes", + config: AIConfig{PatrolIntervalMinutes: 30}, + expected: 30 * time.Minute, + }, + { + name: "old 15min default migrated to 6hr", + config: AIConfig{PatrolIntervalMinutes: 15}, + expected: 6 * time.Hour, + }, + { + name: "default 6hr", + config: AIConfig{}, + expected: 6 * time.Hour, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.config.GetPatrolInterval() + if result != tt.expected { + t.Errorf("GetPatrolInterval() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestPresetToMinutes(t *testing.T) { + tests := []struct { + preset string + expected int + }{ + {"15min", 15}, + {"1hr", 60}, + {"6hr", 360}, + {"12hr", 720}, + {"daily", 1440}, + {"disabled", 0}, + {"unknown", 360}, // default + } + + for _, tt := range tests { + t.Run(tt.preset, func(t *testing.T) { + result := PresetToMinutes(tt.preset) + if result != tt.expected { + t.Errorf("PresetToMinutes(%q) = %d, want %d", tt.preset, result, tt.expected) + } + }) + } +} + +func TestAIConfig_IsPatrolEnabled(t *testing.T) { + tests := []struct { + name string + config AIConfig + expected bool + }{ + { + name: "patrol disabled by preset", + config: AIConfig{PatrolEnabled: true, PatrolSchedulePreset: "disabled"}, + expected: false, + }, + { + name: "patrol enabled", + config: AIConfig{PatrolEnabled: true}, + expected: true, + }, + { + name: "patrol disabled by flag", + config: AIConfig{PatrolEnabled: false}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.config.IsPatrolEnabled() + if result != tt.expected { + t.Errorf("IsPatrolEnabled() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestAIConfig_IsAlertTriggeredAnalysisEnabled(t *testing.T) { + t.Run("enabled", func(t *testing.T) { + config := AIConfig{AlertTriggeredAnalysis: true} + if !config.IsAlertTriggeredAnalysisEnabled() { + t.Error("expected true") + } + }) + + t.Run("disabled", func(t *testing.T) { + config := AIConfig{AlertTriggeredAnalysis: false} + if config.IsAlertTriggeredAnalysisEnabled() { + t.Error("expected false") + } + }) +} + +func TestGetAvailableModels(t *testing.T) { + // This function is deprecated and should return nil + result := GetAvailableModels(AIProviderAnthropic) + if result != nil { + t.Error("GetAvailableModels should return nil (deprecated)") + } +} + +func TestNewDefaultAIConfig(t *testing.T) { + config := NewDefaultAIConfig() + + if config.Enabled { + t.Error("Default should not be enabled") + } + if config.Provider != AIProviderAnthropic { + t.Errorf("Default provider should be anthropic, got %q", config.Provider) + } + if config.PatrolIntervalMinutes != 360 { + t.Errorf("Default patrol interval should be 360, got %d", config.PatrolIntervalMinutes) + } + if !config.PatrolEnabled { + t.Error("Default patrol should be enabled") + } + if !config.AlertTriggeredAnalysis { + t.Error("Default alert triggered analysis should be enabled") + } +} diff --git a/internal/config/persistence_ai_test.go b/internal/config/persistence_ai_test.go new file mode 100644 index 0000000..f426a80 --- /dev/null +++ b/internal/config/persistence_ai_test.go @@ -0,0 +1,131 @@ +package config_test + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" +) + +func TestAIConfigPersistence(t *testing.T) { + tempDir := t.TempDir() + cp := config.NewConfigPersistence(tempDir) + if err := cp.EnsureConfigDir(); err != nil { + t.Fatalf("EnsureConfigDir: %v", err) + } + + cfg := config.AIConfig{ + Enabled: true, + Provider: "anthropic", + APIKey: "test-key", + Model: "claude-3-opus", + } + + if err := cp.SaveAIConfig(cfg); err != nil { + t.Fatalf("SaveAIConfig: %v", err) + } + + loaded, err := cp.LoadAIConfig() + if err != nil { + t.Fatalf("LoadAIConfig: %v", err) + } + + if loaded.Enabled != cfg.Enabled || loaded.Provider != cfg.Provider || loaded.APIKey != cfg.APIKey { + t.Errorf("Loaded config mismatch: %+v", loaded) + } +} + +func TestAIFindingsPersistence(t *testing.T) { + tempDir := t.TempDir() + cp := config.NewConfigPersistence(tempDir) + if err := cp.EnsureConfigDir(); err != nil { + t.Fatalf("EnsureConfigDir: %v", err) + } + + findings := map[string]*config.AIFindingRecord{ + "f1": { + ID: "f1", + Title: "Test Finding", + Severity: "warning", + ResourceID: "res-1", + }, + } + + if err := cp.SaveAIFindings(findings); err != nil { + t.Fatalf("SaveAIFindings: %v", err) + } + + loaded, err := cp.LoadAIFindings() + if err != nil { + t.Fatalf("LoadAIFindings: %v", err) + } + + if len(loaded.Findings) != 1 || loaded.Findings["f1"].Title != "Test Finding" { + t.Errorf("Loaded findings mismatch: %+v", loaded) + } +} + +func TestIsEncryptionEnabled(t *testing.T) { + tempDir := t.TempDir() + cp := config.NewConfigPersistence(tempDir) + + // NewConfigPersistence always enables encryption by generating a key if missing + if !cp.IsEncryptionEnabled() { + t.Error("Encryption should be enabled by default") + } + + // Verify the key file was created + keyPath := filepath.Join(tempDir, ".encryption.key") + if _, err := os.Stat(keyPath); os.IsNotExist(err) { + t.Error("Encryption key file should be created automatically") + } +} + +func TestMetadataPersistence(t *testing.T) { + tempDir := t.TempDir() + cp := config.NewConfigPersistence(tempDir) + + // 1. Guest Metadata + guestMeta := map[string]*config.GuestMetadata{ + "guest-1": { + ID: "guest-1", + Notes: []string{"Important guest"}, + }, + } + + // Create the file manually since SaveGuestMetadata doesn't exist in ConfigPersistence (it's in GuestMetadataStore) + // but LoadGuestMetadata is in ConfigPersistence. + // This tests the LoadGuestMetadata method in persistence.go + guestFile := filepath.Join(tempDir, "guest_metadata.json") + data, _ := json.Marshal(guestMeta) + os.WriteFile(guestFile, data, 0644) + + loadedGuest, err := cp.LoadGuestMetadata() + if err != nil { + t.Fatalf("LoadGuestMetadata failed: %v", err) + } + if len(loadedGuest) != 1 || loadedGuest["guest-1"].Notes[0] != "Important guest" { + t.Errorf("Loaded guest metadata mismatch: %+v", loadedGuest) + } + + // 2. Docker Metadata + dockerMeta := map[string]*config.DockerMetadata{ + "docker-1": { + ID: "docker-1", + Notes: []string{"Worker node"}, + }, + } + dockerFile := filepath.Join(tempDir, "docker_metadata.json") + data, _ = json.Marshal(dockerMeta) + os.WriteFile(dockerFile, data, 0644) + + loadedDocker, err := cp.LoadDockerMetadata() + if err != nil { + t.Fatalf("LoadDockerMetadata failed: %v", err) + } + if len(loadedDocker) != 1 || loadedDocker["docker-1"].Notes[0] != "Worker node" { + t.Errorf("Loaded docker metadata mismatch: %+v", loadedDocker) + } +} diff --git a/internal/config/persistence_extended_test.go b/internal/config/persistence_extended_test.go new file mode 100644 index 0000000..e1401b5 --- /dev/null +++ b/internal/config/persistence_extended_test.go @@ -0,0 +1,131 @@ +package config_test + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/notifications" +) + +func TestConfigPersistence_DataDir(t *testing.T) { + tempDir := t.TempDir() + cp := config.NewConfigPersistence(tempDir) + if cp.DataDir() != tempDir { + t.Errorf("Expected %s, got %s", tempDir, cp.DataDir()) + } +} + +func TestConfigPersistence_MigrateWebhooksIfNeeded(t *testing.T) { + tempDir := t.TempDir() + cp := config.NewConfigPersistence(tempDir) + + // 1. Create legacy file + legacyFile := filepath.Join(tempDir, "webhooks.json") + legacyWebhooks := []notifications.WebhookConfig{ + {ID: "webhook-1", URL: "http://example.com/legacy"}, + } + data, _ := json.Marshal(legacyWebhooks) + os.WriteFile(legacyFile, data, 0644) + + // 2. Migrate + if err := cp.MigrateWebhooksIfNeeded(); err != nil { + t.Fatalf("MigrateWebhooksIfNeeded failed: %v", err) + } + + // 3. Verify encrypted file exists (since SaveWebhooks uses encryption if key exists) + // NewConfigPersistence generates a key if it doesn't exist, so encryption IS enabled by default. + loaded, err := cp.LoadWebhooks() + if err != nil { + t.Fatalf("LoadWebhooks failed: %v", err) + } + if len(loaded) != 1 || loaded[0].URL != "http://example.com/legacy" { + t.Errorf("Migration failed to preserve data: %+v", loaded) + } +} + +func TestConfigPersistence_PatrolRunHistory(t *testing.T) { + tempDir := t.TempDir() + cp := config.NewConfigPersistence(tempDir) + + runs := []config.PatrolRunRecord{ + { + ID: "run-1", + StartedAt: time.Now().Add(-1 * time.Hour), + CompletedAt: time.Now().Add(-59 * time.Minute), + DurationMs: 60000, + Type: "quick", + ResourcesChecked: 10, + NewFindings: 2, + }, + } + + if err := cp.SavePatrolRunHistory(runs); err != nil { + t.Fatalf("SavePatrolRunHistory failed: %v", err) + } + + history, err := cp.LoadPatrolRunHistory() + if err != nil { + t.Fatalf("LoadPatrolRunHistory failed: %v", err) + } + + if len(history.Runs) != 1 || history.Runs[0].ID != "run-1" { + t.Errorf("Patrol history mismatch: %+v", history) + } + + // Test non-existent file + cp2 := config.NewConfigPersistence(t.TempDir()) + history2, err := cp2.LoadPatrolRunHistory() + if err != nil { + t.Fatalf("LoadPatrolRunHistory on empty dir failed: %v", err) + } + if len(history2.Runs) != 0 { + t.Errorf("Expected 0 runs, got %d", len(history2.Runs)) + } +} + +func TestConfigPersistence_UpdateEnvFile(t *testing.T) { + tempDir := t.TempDir() + envFile := filepath.Join(tempDir, ".env") + + initialContent := `UPDATE_CHANNEL=stable +AUTO_UPDATE_ENABLED=false +POLLING_INTERVAL=10 +CUSTOM_VAR=value` + os.WriteFile(envFile, []byte(initialContent), 0644) + + cp := config.NewConfigPersistence(tempDir) + + settings := config.SystemSettings{ + UpdateChannel: "beta", + AutoUpdateEnabled: true, + AutoUpdateCheckInterval: 3600, + } + + if err := cp.SaveSystemSettings(settings); err != nil { + t.Fatalf("SaveSystemSettings failed: %v", err) + } + + updatedData, err := os.ReadFile(envFile) + if err != nil { + t.Fatalf("Failed to read .env file: %v", err) + } + updatedContent := string(updatedData) + + if !strings.Contains(updatedContent, "UPDATE_CHANNEL=beta") { + t.Errorf("UPDATE_CHANNEL not updated. Content: %s", updatedContent) + } + if !strings.Contains(updatedContent, "AUTO_UPDATE_ENABLED=true") { + t.Errorf("AUTO_UPDATE_ENABLED not updated. Content: %s", updatedContent) + } + if strings.Contains(updatedContent, "POLLING_INTERVAL=") { + t.Error("POLLING_INTERVAL should have been removed") + } + if !strings.Contains(updatedContent, "CUSTOM_VAR=value") { + t.Error("CUSTOM_VAR should have been preserved") + } +} diff --git a/internal/license/license_test.go b/internal/license/license_test.go index 853c5c1..762ec49 100644 --- a/internal/license/license_test.go +++ b/internal/license/license_test.go @@ -1,6 +1,9 @@ package license import ( + "crypto/ed25519" + "encoding/base64" + "encoding/json" "os" "testing" "time" @@ -11,6 +14,7 @@ func init() { os.Setenv("PULSE_LICENSE_DEV_MODE", "true") } + func TestTierHasFeature(t *testing.T) { tests := []struct { name string @@ -178,6 +182,8 @@ func TestServiceFeatureGating(t *testing.T) { } // Activate test license + SetPublicKey(nil) + os.Setenv("PULSE_LICENSE_DEV_MODE", "true") testKey, err := GenerateLicenseForTesting("test@example.com", TierPro, 30*24*time.Hour) if err != nil { t.Fatalf("Failed to generate test license: %v", err) @@ -233,6 +239,7 @@ func TestValidateLicenseMalformed(t *testing.T) { {"two parts", "part1.part2"}, {"bad base64 header", "!!!.part2.part3"}, {"bad base64 payload", "eyJhbGciOiJFZERTQSJ9.!!!.part3"}, + {"bad base64 signature", "eyJhbGciOiJFZERTQSJ9.eyJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20ifQ.!!!"}, } for _, tt := range tests { @@ -245,6 +252,56 @@ func TestValidateLicenseMalformed(t *testing.T) { } } +func TestValidateLicense_RequiredFields(t *testing.T) { + os.Setenv("PULSE_LICENSE_DEV_MODE", "true") + defer os.Unsetenv("PULSE_LICENSE_DEV_MODE") + + tests := []struct { + name string + claims map[string]interface{} + }{ + {"missing id", map[string]interface{}{"email": "t@e.c", "tier": "pro"}}, + {"missing email", map[string]interface{}{"lid": "test", "tier": "pro"}}, + {"missing tier", map[string]interface{}{"lid": "test", "email": "t@e.c"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"EdDSA","typ":"JWT"}`)) + payloadBytes, _ := json.Marshal(tt.claims) + payload := base64.RawURLEncoding.EncodeToString(payloadBytes) + key := header + "." + payload + ".fake-sig" + + _, err := ValidateLicense(key) + if err == nil { + t.Error("Expected error for missing required fields") + } + }) + } +} + +func TestValidateLicense_ExpiredPastGrace(t *testing.T) { + os.Setenv("PULSE_LICENSE_DEV_MODE", "true") + defer os.Unsetenv("PULSE_LICENSE_DEV_MODE") + + claims := Claims{ + LicenseID: "test-expired", + Email: "t@e.c", + Tier: TierPro, + ExpiresAt: time.Now().Add(-10 * 24 * time.Hour).Unix(), // 10 days ago (past 7-day grace) + } + + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"EdDSA","typ":"JWT"}`)) + payloadBytes, _ := json.Marshal(claims) + payload := base64.RawURLEncoding.EncodeToString(payloadBytes) + key := header + "." + payload + ".fake-sig" + + _, err := ValidateLicense(key) + if err == nil { + t.Error("Expected error for license past grace period") + } +} + func TestLicenseStatus(t *testing.T) { service := NewService() @@ -259,8 +316,12 @@ func TestLicenseStatus(t *testing.T) { // Activate license SetPublicKey(nil) // Skip signature check for testing + os.Setenv("PULSE_LICENSE_DEV_MODE", "true") testKey, _ := GenerateLicenseForTesting("test@example.com", TierLifetime, 0) - _, _ = service.Activate(testKey) + _, err := service.Activate(testKey) + if err != nil { + t.Fatalf("Failed to activate test license: %v", err) + } status = service.Status() if !status.Valid { @@ -380,3 +441,280 @@ func TestStatusSetsGracePeriodDynamically(t *testing.T) { t.Error("HasFeature should return true during grace period") } } + +func TestServiceCurrent(t *testing.T) { + service := NewService() + + // No license - Current() returns nil + if service.Current() != nil { + t.Error("Current() should return nil when no license") + } + + // Activate license + SetPublicKey(nil) + os.Setenv("PULSE_LICENSE_DEV_MODE", "true") + testKey, err := GenerateLicenseForTesting("test@example.com", TierPro, 30*24*time.Hour) + if err != nil { + t.Fatalf("Failed to generate test license: %v", err) + } + + _, err = service.Activate(testKey) + if err != nil { + t.Fatalf("Failed to activate: %v", err) + } + + // Current() should return the license + lic := service.Current() + if lic == nil { + t.Fatal("Current() should return license after activation") + } + if lic.Claims.Email != "test@example.com" { + t.Errorf("Expected email 'test@example.com', got %q", lic.Claims.Email) + } + + // Clear and verify Current() returns nil again + service.Clear() + if service.Current() != nil { + t.Error("Current() should return nil after Clear()") + } +} + +func TestServiceGetLicenseState(t *testing.T) { + t.Run("no license", func(t *testing.T) { + service := NewService() + state, lic := service.GetLicenseState() + if state != LicenseStateNone { + t.Errorf("Expected state 'none', got %q", state) + } + if lic != nil { + t.Error("Expected nil license") + } + }) + + t.Run("active license", func(t *testing.T) { + service := NewService() + SetPublicKey(nil) + os.Setenv("PULSE_LICENSE_DEV_MODE", "true") + + testKey, _ := GenerateLicenseForTesting("test@example.com", TierPro, 30*24*time.Hour) + _, err := service.Activate(testKey) + if err != nil { + t.Fatalf("Failed to activate: %v", err) + } + + state, lic := service.GetLicenseState() + if state != LicenseStateActive { + t.Errorf("Expected state 'active', got %q", state) + } + if lic == nil { + t.Error("Expected license to be returned") + } + }) + + t.Run("expired license in grace period", func(t *testing.T) { + service := NewService() + + // Create an expired license within grace period (3 days ago) + expiredAt := time.Now().Add(-3 * 24 * time.Hour) + lic := &License{ + Claims: Claims{ + LicenseID: "test_expired", + Email: "test@example.com", + Tier: TierPro, + IssuedAt: time.Now().Add(-33 * 24 * time.Hour).Unix(), + ExpiresAt: expiredAt.Unix(), + }, + ValidatedAt: time.Now().Add(-33 * 24 * time.Hour), + } + + service.mu.Lock() + service.license = lic + service.mu.Unlock() + + state, returnedLic := service.GetLicenseState() + if state != LicenseStateGracePeriod { + t.Errorf("Expected state 'grace_period', got %q", state) + } + if returnedLic == nil { + t.Error("Expected license to be returned") + } + // Should have set grace period end + if returnedLic.GracePeriodEnd == nil { + t.Error("Expected GracePeriodEnd to be set") + } + }) + + t.Run("expired license past grace period", func(t *testing.T) { + service := NewService() + + // Create an expired license past grace period (10 days ago) + expiredAt := time.Now().Add(-10 * 24 * time.Hour) + gracePeriodEnd := expiredAt.Add(7 * 24 * time.Hour) // Grace ended 3 days ago + lic := &License{ + Claims: Claims{ + LicenseID: "test_expired_past", + Email: "test@example.com", + Tier: TierPro, + IssuedAt: time.Now().Add(-40 * 24 * time.Hour).Unix(), + ExpiresAt: expiredAt.Unix(), + }, + ValidatedAt: time.Now().Add(-40 * 24 * time.Hour), + GracePeriodEnd: &gracePeriodEnd, + } + + service.mu.Lock() + service.license = lic + service.mu.Unlock() + + state, returnedLic := service.GetLicenseState() + if state != LicenseStateExpired { + t.Errorf("Expected state 'expired', got %q", state) + } + if returnedLic == nil { + t.Error("Expected license to be returned") + } + }) +} + +func TestServiceGetLicenseStateString(t *testing.T) { + t.Run("no license", func(t *testing.T) { + service := NewService() + stateStr, hasFeatures := service.GetLicenseStateString() + if stateStr != "none" { + t.Errorf("Expected state string 'none', got %q", stateStr) + } + if hasFeatures { + t.Error("Expected hasFeatures to be false for no license") + } + }) + + t.Run("active license", func(t *testing.T) { + service := NewService() + SetPublicKey(nil) + os.Setenv("PULSE_LICENSE_DEV_MODE", "true") + + testKey, _ := GenerateLicenseForTesting("test@example.com", TierPro, 30*24*time.Hour) + service.Activate(testKey) + + stateStr, hasFeatures := service.GetLicenseStateString() + if stateStr != "active" { + t.Errorf("Expected state string 'active', got %q", stateStr) + } + if !hasFeatures { + t.Error("Expected hasFeatures to be true for active license") + } + }) + + t.Run("grace period", func(t *testing.T) { + service := NewService() + + expiredAt := time.Now().Add(-3 * 24 * time.Hour) + lic := &License{ + Claims: Claims{ + LicenseID: "test_grace", + Email: "test@example.com", + Tier: TierPro, + ExpiresAt: expiredAt.Unix(), + }, + } + + service.mu.Lock() + service.license = lic + service.mu.Unlock() + + stateStr, hasFeatures := service.GetLicenseStateString() + if stateStr != "grace_period" { + t.Errorf("Expected state string 'grace_period', got %q", stateStr) + } + if !hasFeatures { + t.Error("Expected hasFeatures to be true during grace period") + } + }) +} + +func TestServiceSetLicenseChangeCallback(t *testing.T) { + service := NewService() + + var callbackLicense *License + callbackCalled := false + + service.SetLicenseChangeCallback(func(lic *License) { + callbackCalled = true + callbackLicense = lic + }) + + // Activate license - should trigger callback + SetPublicKey(nil) + os.Setenv("PULSE_LICENSE_DEV_MODE", "true") + testKey, _ := GenerateLicenseForTesting("callback@example.com", TierPro, 30*24*time.Hour) + _, err := service.Activate(testKey) + if err != nil { + t.Fatalf("Failed to activate: %v", err) + } + + if !callbackCalled { + t.Error("Callback should have been called on Activate") + } + if callbackLicense == nil { + t.Error("Callback should receive the license") + } + if callbackLicense != nil && callbackLicense.Claims.Email != "callback@example.com" { + t.Errorf("Callback received wrong license, email: %q", callbackLicense.Claims.Email) + } + + // Reset for Clear test + callbackCalled = false + callbackLicense = nil + + // Clear license - should trigger callback with nil + service.Clear() + + if !callbackCalled { + t.Error("Callback should have been called on Clear") + } + if callbackLicense != nil { + t.Error("Callback should receive nil on Clear") + } +} + +func TestValidateLicense_RealSignature(t *testing.T) { + pub, priv, _ := ed25519.GenerateKey(nil) + SetPublicKey(pub) + defer SetPublicKey(nil) + + os.Setenv("PULSE_LICENSE_DEV_MODE", "false") + defer os.Setenv("PULSE_LICENSE_DEV_MODE", "true") + + claims := Claims{ + LicenseID: "test-sig", + Email: "t@e.c", + Tier: TierPro, + IssuedAt: time.Now().Unix(), + ExpiresAt: time.Now().Add(30 * 24 * time.Hour).Unix(), + } + + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"EdDSA","typ":"JWT"}`)) + payloadBytes, _ := json.Marshal(claims) + payload := base64.RawURLEncoding.EncodeToString(payloadBytes) + + signedData := header + "." + payload + signature := ed25519.Sign(priv, []byte(signedData)) + sigEncoded := base64.RawURLEncoding.EncodeToString(signature) + + key := signedData + "." + sigEncoded + + lic, err := ValidateLicense(key) + if err != nil { + t.Fatalf("Failed to validate license with real signature: %v", err) + } + if lic.Claims.Email != "t@e.c" { + t.Error("Email mismatch in validated license") + } + + // Test invalid signature + badKey := signedData + "." + base64.RawURLEncoding.EncodeToString([]byte("invalid-signature-length-must-be-64-bytes-long-12345678901234567890")) + _, err = ValidateLicense(badKey) + if err == nil { + t.Error("Expected error for invalid signature") + } +} diff --git a/internal/license/persistence_test.go b/internal/license/persistence_test.go new file mode 100644 index 0000000..b9da9ff --- /dev/null +++ b/internal/license/persistence_test.go @@ -0,0 +1,140 @@ +package license + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestPersistence(t *testing.T) { + // Create a temporary directory for config + tmpDir, err := os.MkdirTemp("", "pulse-license-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + p, err := NewPersistence(tmpDir) + if err != nil { + t.Fatalf("Failed to create persistence: %v", err) + } + + testLicenseKey := "test-license-key-123" + + t.Run("Save and Load", func(t *testing.T) { + err := p.Save(testLicenseKey) + if err != nil { + t.Fatalf("Failed to save license: %v", err) + } + + if !p.Exists() { + t.Error("License file should exist") + } + + loadedKey, err := p.Load() + if err != nil { + t.Fatalf("Failed to load license: %v", err) + } + + if loadedKey != testLicenseKey { + t.Errorf("Expected license key %s, got %s", testLicenseKey, loadedKey) + } + }) + + t.Run("Save and Load with Grace Period", func(t *testing.T) { + gracePeriodEnd := time.Now().Add(7 * 24 * time.Hour).Unix() + err := p.SaveWithGracePeriod(testLicenseKey, &gracePeriodEnd) + if err != nil { + t.Fatalf("Failed to save license with grace period: %v", err) + } + + persisted, err := p.LoadWithMetadata() + if err != nil { + t.Fatalf("Failed to load license with metadata: %v", err) + } + + if persisted.LicenseKey != testLicenseKey { + t.Errorf("Expected license key %s, got %s", testLicenseKey, persisted.LicenseKey) + } + + if persisted.GracePeriodEnd == nil || *persisted.GracePeriodEnd != gracePeriodEnd { + t.Errorf("Expected grace period end %v, got %v", gracePeriodEnd, persisted.GracePeriodEnd) + } + }) + + t.Run("Load non-existent", func(t *testing.T) { + tmpDirEmpty, _ := os.MkdirTemp("", "pulse-license-test-empty-*") + defer os.RemoveAll(tmpDirEmpty) + + pEmpty, _ := NewPersistence(tmpDirEmpty) + key, err := pEmpty.Load() + if err != nil { + t.Fatalf("Expected no error for non-existent license, got %v", err) + } + if key != "" { + t.Errorf("Expected empty key, got %s", key) + } + }) + + t.Run("Delete", func(t *testing.T) { + err := p.Save(testLicenseKey) + if err != nil { + t.Fatalf("Failed to save license: %v", err) + } + + if !p.Exists() { + t.Fatal("License should exist before delete") + } + + err = p.Delete() + if err != nil { + t.Fatalf("Failed to delete license: %v", err) + } + + if p.Exists() { + t.Error("License should not exist after delete") + } + }) + + t.Run("Encryption check", func(t *testing.T) { + err := p.Save(testLicenseKey) + if err != nil { + t.Fatalf("Failed to save license: %v", err) + } + + // Read the file directly - it should be base64 encoded and encrypted + licensePath := filepath.Join(tmpDir, LicenseFileName) + data, err := os.ReadFile(licensePath) + if err != nil { + t.Fatalf("Failed to read license file: %v", err) + } + + if string(data) == testLicenseKey { + t.Error("License file should be encrypted, not raw text") + } + + // Ensure it's not JSON either in raw form + if data[0] == '{' { + t.Error("License file should be encrypted, not raw JSON") + } + }) + + t.Run("Decrypt with wrong key material", func(t *testing.T) { + err := p.Save(testLicenseKey) + if err != nil { + t.Fatalf("Failed to save license: %v", err) + } + + // Create a new persistence with different machine ID + pWrong := &Persistence{ + configDir: tmpDir, + machineID: "different-machine-id", + } + + _, err = pWrong.Load() + if err == nil { + t.Error("Expected error when decrypting with wrong key material") + } + }) +} diff --git a/internal/license/pubkey_test.go b/internal/license/pubkey_test.go new file mode 100644 index 0000000..1bb77a0 --- /dev/null +++ b/internal/license/pubkey_test.go @@ -0,0 +1,117 @@ +package license + +import ( + "crypto/ed25519" + "encoding/base64" + "os" + "testing" +) + +func TestInitPublicKey(t *testing.T) { + // Generate a temporary key pair for testing + pub, _, _ := ed25519.GenerateKey(nil) + base64Pub := base64.StdEncoding.EncodeToString(pub) + + tests := []struct { + name string + envKey string + embeddedKey string + devMode string + expectedLoaded bool + }{ + { + name: "load from environment", + envKey: base64Pub, + expectedLoaded: true, + }, + { + name: "load from embedded", + embeddedKey: base64Pub, + expectedLoaded: true, + }, + { + name: "dev mode", + devMode: "true", + expectedLoaded: false, // In dev mode it doesn't set the key + }, + { + name: "no key available", + expectedLoaded: false, + }, + { + name: "malformed env key falls back", + envKey: "not-base64", + embeddedKey: base64Pub, + expectedLoaded: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Clear state + SetPublicKey(nil) + os.Unsetenv("PULSE_LICENSE_PUBLIC_KEY") + os.Unsetenv("PULSE_LICENSE_DEV_MODE") + EmbeddedPublicKey = tt.embeddedKey + + if tt.envKey != "" { + os.Setenv("PULSE_LICENSE_PUBLIC_KEY", tt.envKey) + } + if tt.devMode != "" { + os.Setenv("PULSE_LICENSE_DEV_MODE", tt.devMode) + } + + InitPublicKey() + + loaded := publicKey != nil + if loaded != tt.expectedLoaded { + t.Errorf("expectedLoaded = %v, got %v", tt.expectedLoaded, loaded) + } + }) + } + + // Clean up for other tests + os.Unsetenv("PULSE_LICENSE_PUBLIC_KEY") + os.Unsetenv("PULSE_LICENSE_DEV_MODE") + EmbeddedPublicKey = "" +} + +func TestDecodePublicKey(t *testing.T) { + pub, _, _ := ed25519.GenerateKey(nil) + + tests := []struct { + name string + input string + wantErr bool + }{ + { + name: "valid standard base64", + input: base64.StdEncoding.EncodeToString(pub), + wantErr: false, + }, + { + name: "valid URL-safe base64", + input: base64.RawURLEncoding.EncodeToString(pub), + wantErr: false, + }, + { + name: "invalid base64", + input: "!!!", + wantErr: true, + }, + { + name: "wrong size", + input: base64.StdEncoding.EncodeToString([]byte("too-short")), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := decodePublicKey(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("decodePublicKey() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/internal/monitoring/host_agent_temps_test.go b/internal/monitoring/host_agent_temps_test.go new file mode 100644 index 0000000..4b1f484 --- /dev/null +++ b/internal/monitoring/host_agent_temps_test.go @@ -0,0 +1,304 @@ +package monitoring + +import ( + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/models" +) + +func TestConvertHostSensorsToTemperature_Empty(t *testing.T) { + sensors := models.HostSensorSummary{} + result := convertHostSensorsToTemperature(sensors, time.Now()) + if result != nil { + t.Error("expected nil for empty sensors") + } +} + +func TestConvertHostSensorsToTemperature_CPUOnly(t *testing.T) { + sensors := models.HostSensorSummary{ + TemperatureCelsius: map[string]float64{ + "cpu_package": 55.0, + "cpu_core_0": 50.0, + "cpu_core_1": 52.0, + }, + } + now := time.Now() + result := convertHostSensorsToTemperature(sensors, now) + + if result == nil { + t.Fatal("expected non-nil result") + } + if !result.Available { + t.Error("expected Available to be true") + } + if !result.HasCPU { + t.Error("expected HasCPU to be true") + } + if result.CPUPackage != 55.0 { + t.Errorf("expected CPUPackage 55.0, got %f", result.CPUPackage) + } + if len(result.Cores) != 2 { + t.Errorf("expected 2 cores, got %d", len(result.Cores)) + } + // Cores should be sorted + if result.Cores[0].Core != 0 || result.Cores[1].Core != 1 { + t.Error("cores not sorted correctly") + } + if result.CPUMax != 52.0 { // Max of core temps + t.Errorf("expected CPUMax 52.0, got %f", result.CPUMax) + } +} + +func TestConvertHostSensorsToTemperature_NVMe(t *testing.T) { + sensors := models.HostSensorSummary{ + TemperatureCelsius: map[string]float64{ + "cpu_package": 45.0, + "nvme0": 40.0, + "nvme1": 42.0, + }, + } + result := convertHostSensorsToTemperature(sensors, time.Now()) + + if result == nil { + t.Fatal("expected non-nil result") + } + if !result.HasNVMe { + t.Error("expected HasNVMe to be true") + } + if len(result.NVMe) != 2 { + t.Errorf("expected 2 NVMe devices, got %d", len(result.NVMe)) + } + // NVMe should be sorted + if result.NVMe[0].Device != "nvme0" || result.NVMe[1].Device != "nvme1" { + t.Error("NVMe devices not sorted correctly") + } +} + +func TestConvertHostSensorsToTemperature_GPU(t *testing.T) { + sensors := models.HostSensorSummary{ + TemperatureCelsius: map[string]float64{ + "cpu_package": 45.0, + "gpu_edge": 60.0, + "gpu_junction": 65.0, + "gpu_mem": 55.0, + }, + } + result := convertHostSensorsToTemperature(sensors, time.Now()) + + if result == nil { + t.Fatal("expected non-nil result") + } + if !result.HasGPU { + t.Error("expected HasGPU to be true") + } + if len(result.GPU) != 1 { + t.Errorf("expected 1 GPU, got %d", len(result.GPU)) + } + if result.GPU[0].Device != "gpu0" { + t.Errorf("expected device 'gpu0', got %q", result.GPU[0].Device) + } + if result.GPU[0].Edge != 60.0 { + t.Errorf("expected Edge 60.0, got %f", result.GPU[0].Edge) + } + if result.GPU[0].Junction != 65.0 { + t.Errorf("expected Junction 65.0, got %f", result.GPU[0].Junction) + } +} + +func TestConvertHostSensorsToTemperature_GenericGPU(t *testing.T) { + sensors := models.HostSensorSummary{ + TemperatureCelsius: map[string]float64{ + "cpu_package": 45.0, + "gpu_nvidia": 70.0, + }, + } + result := convertHostSensorsToTemperature(sensors, time.Now()) + + if result == nil { + t.Fatal("expected non-nil result") + } + if !result.HasGPU { + t.Error("expected HasGPU to be true") + } + if len(result.GPU) != 1 { + t.Errorf("expected 1 GPU, got %d", len(result.GPU)) + } + if result.GPU[0].Device != "nvidia" { + t.Errorf("expected device 'nvidia', got %q", result.GPU[0].Device) + } + if result.GPU[0].Edge != 70.0 { + t.Errorf("expected Edge 70.0, got %f", result.GPU[0].Edge) + } +} + +func TestConvertHostSensorsToTemperature_NoPackageUsesMaxCore(t *testing.T) { + sensors := models.HostSensorSummary{ + TemperatureCelsius: map[string]float64{ + "cpu_core_0": 50.0, + "cpu_core_1": 55.0, + }, + } + result := convertHostSensorsToTemperature(sensors, time.Now()) + + if result == nil { + t.Fatal("expected non-nil result") + } + // When no package temp, CPUPackage should use max core temp + if result.CPUPackage != 55.0 { + t.Errorf("expected CPUPackage to be max core temp 55.0, got %f", result.CPUPackage) + } +} + +func TestIsHostAgentTemperatureRecent(t *testing.T) { + tests := []struct { + name string + lastSeen time.Time + expected bool + }{ + { + name: "recent - just now", + lastSeen: time.Now(), + expected: true, + }, + { + name: "recent - 1 minute ago", + lastSeen: time.Now().Add(-1 * time.Minute), + expected: true, + }, + { + name: "stale - 3 minutes ago", + lastSeen: time.Now().Add(-3 * time.Minute), + expected: false, + }, + { + name: "stale - 1 hour ago", + lastSeen: time.Now().Add(-1 * time.Hour), + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isHostAgentTemperatureRecent(tt.lastSeen) + if result != tt.expected { + t.Errorf("isHostAgentTemperatureRecent() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestMergeTemperatureData_NilInputs(t *testing.T) { + t.Run("both nil", func(t *testing.T) { + result := mergeTemperatureData(nil, nil) + if result != nil { + t.Error("expected nil for both nil inputs") + } + }) + + t.Run("host nil", func(t *testing.T) { + proxy := &models.Temperature{CPUPackage: 50.0} + result := mergeTemperatureData(nil, proxy) + if result != proxy { + t.Error("expected proxy when host is nil") + } + }) + + t.Run("proxy nil", func(t *testing.T) { + host := &models.Temperature{CPUPackage: 55.0} + result := mergeTemperatureData(host, nil) + if result != host { + t.Error("expected host when proxy is nil") + } + }) +} + +func TestMergeTemperatureData_Merge(t *testing.T) { + hostTemp := &models.Temperature{ + CPUPackage: 55.0, + CPUMax: 55.0, + HasCPU: true, + Cores: []models.CoreTemp{ + {Core: 0, Temp: 50.0}, + {Core: 1, Temp: 55.0}, + }, + LastUpdate: time.Now(), + } + + proxyTemp := &models.Temperature{ + CPUPackage: 52.0, + CPUMin: 30.0, + CPUMaxRecord: 60.0, + HasCPU: true, + HasSMART: true, + SMART: []models.DiskTemp{ + {Device: "/dev/sda", Temperature: 35}, + }, + } + + result := mergeTemperatureData(hostTemp, proxyTemp) + + if result == nil { + t.Fatal("expected non-nil result") + } + + // Host agent CPU takes priority + if result.CPUPackage != 55.0 { + t.Errorf("expected CPUPackage 55.0 from host, got %f", result.CPUPackage) + } + + // Proxy historical data preserved + if result.CPUMin != 30.0 { + t.Errorf("expected CPUMin 30.0 from proxy, got %f", result.CPUMin) + } + + // SMART data from proxy preserved + if !result.HasSMART { + t.Error("expected HasSMART to be true") + } + if len(result.SMART) != 1 { + t.Errorf("expected 1 SMART disk, got %d", len(result.SMART)) + } +} + +func TestMergeTemperatureData_FallbackToProxy(t *testing.T) { + // Host has no CPU data, should fall back to proxy + hostTemp := &models.Temperature{ + HasGPU: true, + GPU: []models.GPUTemp{ + {Device: "gpu0", Edge: 70.0}, + }, + LastUpdate: time.Now(), + } + + proxyTemp := &models.Temperature{ + CPUPackage: 52.0, + CPUMax: 52.0, + HasCPU: true, + Cores: []models.CoreTemp{ + {Core: 0, Temp: 48.0}, + }, + } + + result := mergeTemperatureData(hostTemp, proxyTemp) + + if result == nil { + t.Fatal("expected non-nil result") + } + + // Should fall back to proxy CPU data + if result.CPUPackage != 52.0 { + t.Errorf("expected CPUPackage 52.0 from proxy fallback, got %f", result.CPUPackage) + } + if len(result.Cores) != 1 { + t.Errorf("expected 1 core from proxy, got %d", len(result.Cores)) + } + + // Host GPU data should be present + if !result.HasGPU { + t.Error("expected HasGPU to be true") + } + if len(result.GPU) != 1 { + t.Errorf("expected 1 GPU from host, got %d", len(result.GPU)) + } +}