test: improve test coverage for AI, license, config, and monitoring packages

New test files:
- internal/ai/providers/gemini_test.go: Comprehensive Gemini provider tests
- internal/api/ai_intelligence_handlers_test.go: AI intelligence endpoint tests
- internal/api/ai_patrol_handlers_test.go: AI patrol endpoint tests
- internal/api/license_handlers_test.go: License API handler tests
- internal/api/security_oidc_response_test.go: OIDC response formatting tests
- internal/config/ai_config_test.go: AI configuration function tests
- internal/config/persistence_ai_test.go: AI config persistence tests
- internal/config/persistence_extended_test.go: Extended persistence tests
- internal/license/persistence_test.go: License persistence tests
- internal/license/pubkey_test.go: Public key handling tests
- internal/monitoring/host_agent_temps_test.go: Temperature processing tests

Enhanced existing files:
- internal/api/updates_test.go: Added update handler tests
- internal/license/license_test.go: Added Service method tests

Coverage improvements:
- ai/providers: 57.3% -> 73.0% (+15.7%)
- license: 78.3% -> 85.9% (+7.6%)
- config: 49.7% -> 53.9% (+4.2%)
- monitoring: 49.8% -> 50.8% (+1.0%)
- api: 28.4% -> 29.8% (+1.4%)
This commit is contained in:
rcourtman 2025-12-19 22:49:30 +00:00
parent 1646510450
commit d3eb6a7148
13 changed files with 3542 additions and 1 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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