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:
parent
1646510450
commit
d3eb6a7148
13 changed files with 3542 additions and 1 deletions
529
internal/ai/providers/gemini_test.go
Normal file
529
internal/ai/providers/gemini_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
343
internal/api/ai_intelligence_handlers_test.go
Normal file
343
internal/api/ai_intelligence_handlers_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
298
internal/api/ai_patrol_handlers_test.go
Normal file
298
internal/api/ai_patrol_handlers_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
114
internal/api/license_handlers_test.go
Normal file
114
internal/api/license_handlers_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
182
internal/api/security_oidc_response_test.go
Normal file
182
internal/api/security_oidc_response_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
677
internal/config/ai_config_test.go
Normal file
677
internal/config/ai_config_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
131
internal/config/persistence_ai_test.go
Normal file
131
internal/config/persistence_ai_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
131
internal/config/persistence_extended_test.go
Normal file
131
internal/config/persistence_extended_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
140
internal/license/persistence_test.go
Normal file
140
internal/license/persistence_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
117
internal/license/pubkey_test.go
Normal file
117
internal/license/pubkey_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
304
internal/monitoring/host_agent_temps_test.go
Normal file
304
internal/monitoring/host_agent_temps_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue