Pulse/internal/api/ai_handlers_test.go
2026-04-01 12:00:38 +01:00

1405 lines
41 KiB
Go

package api
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/agentexec"
"github.com/rcourtman/pulse-go-rewrite/internal/ai"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/approval"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newTestAISettingsHandler(cfg *config.Config, persistence *config.ConfigPersistence, agentServer *agentexec.Server) *AISettingsHandler {
handler := NewAISettingsHandler(nil, nil, agentServer)
handler.legacyConfig = cfg
handler.legacyPersistence = persistence
if persistence != nil {
handler.legacyAIService = ai.NewService(persistence, agentServer)
_ = handler.legacyAIService.LoadConfig()
}
return handler
}
func TestAISettingsHandler_GetAndUpdateSettings_RoundTrip(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
// GET should return defaults if no config has been saved yet.
{
req := httptest.NewRequest(http.MethodGet, "/api/settings/ai", nil)
rec := httptest.NewRecorder()
handler.HandleGetAISettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp AISettingsResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Enabled {
t.Fatalf("expected default Enabled=false, got %+v", resp)
}
}
// Update settings to enable AI via Ollama.
{
body, _ := json.Marshal(AISettingsUpdateRequest{
Enabled: ptr(true),
Provider: ptr("ollama"),
Model: ptr("ollama:llama3"),
OllamaBaseURL: ptr("http://localhost:11434"),
OllamaUsername: ptr("unai"),
OllamaPassword: ptr("secret"),
})
req := httptest.NewRequest(http.MethodPut, "/api/settings/ai", bytes.NewReader(body))
rec := httptest.NewRecorder()
handler.HandleUpdateAISettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("PUT status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp AISettingsResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if !resp.Enabled || !resp.OllamaConfigured {
t.Fatalf("expected enabled + ollama configured, got %+v", resp)
}
if resp.OllamaBaseURL != "http://localhost:11434" {
t.Fatalf("unexpected ollama base url: %+v", resp)
}
if resp.OllamaUsername != "unai" || !resp.OllamaPasswordSet {
t.Fatalf("expected ollama auth state in response, got %+v", resp)
}
}
// GET again should reflect persisted updates.
{
req := httptest.NewRequest(http.MethodGet, "/api/settings/ai", nil)
rec := httptest.NewRecorder()
handler.HandleGetAISettings(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp AISettingsResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if !resp.Enabled || !resp.OllamaConfigured {
t.Fatalf("expected enabled + ollama configured, got %+v", resp)
}
if resp.OllamaUsername != "unai" || !resp.OllamaPasswordSet {
t.Fatalf("expected persisted ollama auth state, got %+v", resp)
}
}
}
func TestAISettingsHandler_ListModels_Ollama(t *testing.T) {
t.Parallel()
ollama := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/tags":
_ = json.NewEncoder(w).Encode(map[string]any{
"models": []map[string]any{
{"name": "llama3:latest"},
{"name": "tinyllama:latest"},
},
})
default:
http.NotFound(w, r)
}
}))
defer ollama.Close()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
aiCfg := config.NewDefaultAIConfig()
aiCfg.Enabled = true
aiCfg.Model = "ollama:llama3"
aiCfg.OllamaBaseURL = ollama.URL
if err := persistence.SaveAIConfig(*aiCfg); err != nil {
t.Fatalf("SaveAIConfig: %v", err)
}
handler := newTestAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodGet, "/api/ai/models", nil)
rec := httptest.NewRecorder()
handler.HandleListModels(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp struct {
Models []struct {
ID string `json:"id"`
Name string `json:"name"`
Provider string `json:"provider"`
} `json:"models"`
Error string `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Error != "" {
t.Fatalf("unexpected error: %s", resp.Error)
}
if len(resp.Models) != 2 {
t.Fatalf("expected 2 models, got %+v", resp.Models)
}
for _, model := range resp.Models {
if model.Provider != config.AIProviderOllama {
t.Fatalf("expected ollama provider for model %+v", model)
}
}
}
func TestAISettingsHandler_Execute_Ollama(t *testing.T) {
t.Parallel()
ollama := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/chat":
_ = json.NewEncoder(w).Encode(map[string]any{
"model": "llama3",
"created_at": time.Now().Format(time.RFC3339),
"message": map[string]any{
"role": "assistant",
"content": "hello from ollama",
},
"done": true,
"done_reason": "stop",
"prompt_eval_count": 3,
"eval_count": 5,
})
case "/api/version":
_ = json.NewEncoder(w).Encode(map[string]any{"version": "0.1.0"})
case "/api/tags":
_ = json.NewEncoder(w).Encode(map[string]any{"models": []map[string]any{{"name": "llama3"}}})
default:
http.NotFound(w, r)
}
}))
defer ollama.Close()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
aiCfg := config.NewDefaultAIConfig()
aiCfg.Enabled = true
aiCfg.Model = "ollama:llama3"
aiCfg.OllamaBaseURL = ollama.URL
if err := persistence.SaveAIConfig(*aiCfg); err != nil {
t.Fatalf("SaveAIConfig: %v", err)
}
handler := newTestAISettingsHandler(cfg, persistence, nil)
body, _ := json.Marshal(AIExecuteRequest{Prompt: "hi"})
req := httptest.NewRequest(http.MethodPost, "/api/ai/execute", bytes.NewReader(body))
req = req.WithContext(context.Background())
rec := httptest.NewRecorder()
handler.HandleExecute(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp AIExecuteResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Content != "hello from ollama" || resp.Model == "" {
t.Fatalf("unexpected response: %+v", resp)
}
}
func ptr[T any](v T) *T { return &v }
func TestAISettingsHandler_TestConnection_Ollama(t *testing.T) {
t.Parallel()
versionHits := 0
tagsHits := 0
ollama := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
username, password, ok := r.BasicAuth()
if !ok || username != "unai" || password != "secret" {
t.Fatalf("unexpected basic auth: ok=%v user=%q pass=%q", ok, username, password)
}
switch r.URL.Path {
case "/api/version":
versionHits++
_ = json.NewEncoder(w).Encode(map[string]any{"version": "0.1.0"})
case "/api/tags":
tagsHits++
_ = json.NewEncoder(w).Encode(map[string]any{"models": []map[string]any{{"name": "llama3:latest"}}})
default:
http.NotFound(w, r)
}
}))
defer ollama.Close()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
aiCfg := config.NewDefaultAIConfig()
aiCfg.Enabled = true
aiCfg.Model = "ollama:llama3"
aiCfg.OllamaBaseURL = ollama.URL
aiCfg.OllamaUsername = "unai"
aiCfg.OllamaPassword = "secret"
if err := persistence.SaveAIConfig(*aiCfg); err != nil {
t.Fatalf("SaveAIConfig: %v", err)
}
handler := newTestAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodPost, "/api/ai/test", nil)
rec := httptest.NewRecorder()
handler.HandleTestAIConnection(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp struct {
Success bool `json:"success"`
Message string `json:"message"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if !resp.Success {
t.Fatalf("expected success, got %+v", resp)
}
if versionHits != 1 || tagsHits != 1 {
t.Fatalf("expected version+tags check, got version=%d tags=%d", versionHits, tagsHits)
}
}
func TestAISettingsHandler_TestProvider_Ollama(t *testing.T) {
t.Parallel()
versionHits := 0
tagsHits := 0
ollama := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
username, password, ok := r.BasicAuth()
if !ok || username != "unai" || password != "secret" {
t.Fatalf("unexpected basic auth: ok=%v user=%q pass=%q", ok, username, password)
}
switch r.URL.Path {
case "/api/version":
versionHits++
_ = json.NewEncoder(w).Encode(map[string]any{"version": "0.1.0"})
case "/api/tags":
tagsHits++
_ = json.NewEncoder(w).Encode(map[string]any{"models": []map[string]any{{"name": "llama3:latest"}}})
default:
http.NotFound(w, r)
}
}))
defer ollama.Close()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
aiCfg := config.NewDefaultAIConfig()
aiCfg.Enabled = true
aiCfg.Model = "openai:gpt-4o"
aiCfg.PatrolModel = "ollama:llama3"
aiCfg.OllamaBaseURL = ollama.URL
aiCfg.OllamaUsername = "unai"
aiCfg.OllamaPassword = "secret"
if err := persistence.SaveAIConfig(*aiCfg); err != nil {
t.Fatalf("SaveAIConfig: %v", err)
}
handler := newTestAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodPost, "/api/ai/test/ollama", nil)
rec := httptest.NewRecorder()
handler.HandleTestProvider(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
}
var resp struct {
Success bool `json:"success"`
Provider string `json:"provider"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if !resp.Success || resp.Provider != "ollama" {
t.Fatalf("unexpected response: %+v", resp)
}
if versionHits != 1 || tagsHits != 1 {
t.Fatalf("expected version+tags check, got version=%d tags=%d", versionHits, tagsHits)
}
}
// ========================================
// HandleGetAICostSummary tests
// ========================================
func TestHandleGetAICostSummary_MethodNotAllowed(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodPost, "/api/ai/cost/summary", nil)
rec := httptest.NewRecorder()
handler.HandleGetAICostSummary(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code)
}
}
func TestHandleGetAICostSummary_NoAIService(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodGet, "/api/ai/cost/summary", nil)
rec := httptest.NewRecorder()
handler.HandleGetAICostSummary(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)
}
var resp struct {
Days int `json:"days"`
PricingAsOf string `json:"pricing_as_of"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Days != 30 {
t.Fatalf("expected Days=30 default, got %d", resp.Days)
}
}
func TestHandleGetAICostSummary_CustomDays(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodGet, "/api/ai/cost/summary?days=7", nil)
rec := httptest.NewRecorder()
handler.HandleGetAICostSummary(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)
}
var resp struct {
Days int `json:"days"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Days != 7 {
t.Fatalf("expected Days=7, got %d", resp.Days)
}
}
func TestHandleGetAICostSummary_MaxDays(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
// Test that days > 365 is capped at 365
req := httptest.NewRequest(http.MethodGet, "/api/ai/cost/summary?days=1000", nil)
rec := httptest.NewRecorder()
handler.HandleGetAICostSummary(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)
}
var resp struct {
Days int `json:"days"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Days != 365 {
t.Fatalf("expected Days=365 (capped), got %d", resp.Days)
}
}
// ========================================
// HandleResetAICostHistory tests
// ========================================
func TestHandleResetAICostHistory_MethodNotAllowed(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodGet, "/api/ai/cost/reset", nil)
rec := httptest.NewRecorder()
handler.HandleResetAICostHistory(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code)
}
}
func TestHandleResetAICostHistory_Success(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodPost, "/api/ai/cost/reset", nil)
rec := httptest.NewRecorder()
handler.HandleResetAICostHistory(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)
}
var resp struct {
Ok bool `json:"ok"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if !resp.Ok {
t.Fatalf("expected ok=true")
}
}
// ========================================
// HandleExportAICostHistory tests
// ========================================
func TestHandleExportAICostHistory_MethodNotAllowed(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodPost, "/api/ai/cost/export", nil)
rec := httptest.NewRecorder()
handler.HandleExportAICostHistory(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code)
}
}
// ========================================
// HandleGetSuppressionRules tests
// ========================================
func TestHandleGetSuppressionRules_MethodNotAllowed(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodPost, "/api/ai/patrol/suppressions", nil)
rec := httptest.NewRecorder()
handler.HandleGetSuppressionRules(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code)
}
}
// ========================================
// HandleAddSuppressionRule tests
// ========================================
func TestHandleAddSuppressionRule_MethodNotAllowed(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodGet, "/api/ai/patrol/suppressions", nil)
rec := httptest.NewRecorder()
handler.HandleAddSuppressionRule(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code)
}
}
// ========================================
// HandleDeleteSuppressionRule tests
// ========================================
func TestHandleDeleteSuppressionRule_MethodNotAllowed(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodGet, "/api/ai/patrol/suppressions/rule-123", nil)
rec := httptest.NewRecorder()
handler.HandleDeleteSuppressionRule(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code)
}
}
// ========================================
// HandleGetDismissedFindings tests
// ========================================
func TestHandleGetDismissedFindings_MethodNotAllowed(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodPost, "/api/ai/patrol/dismissed", nil)
rec := httptest.NewRecorder()
handler.HandleGetDismissedFindings(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code)
}
}
// ========================================
// HandleGetGuestKnowledge tests
// ========================================
func TestHandleGetGuestKnowledge_MissingGuestID(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodGet, "/api/ai/knowledge", nil)
rec := httptest.NewRecorder()
handler.HandleGetGuestKnowledge(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rec.Code)
}
}
// ========================================
// HandleSaveGuestNote tests
// ========================================
func TestHandleSaveGuestNote_InvalidBody(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodPost, "/api/ai/knowledge", bytes.NewReader([]byte(`{invalid json}`)))
rec := httptest.NewRecorder()
handler.HandleSaveGuestNote(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rec.Code)
}
}
func TestHandleSaveGuestNote_MissingFields(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
body := []byte(`{"guest_id": "vm-100"}`)
req := httptest.NewRequest(http.MethodPost, "/api/ai/knowledge", bytes.NewReader(body))
rec := httptest.NewRecorder()
handler.HandleSaveGuestNote(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rec.Code)
}
}
// ========================================
// HandleDeleteGuestNote tests
// ========================================
func TestHandleDeleteGuestNote_InvalidBody(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodPost, "/api/ai/knowledge/delete", bytes.NewReader([]byte(`{invalid json}`)))
rec := httptest.NewRecorder()
handler.HandleDeleteGuestNote(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rec.Code)
}
}
func TestHandleDeleteGuestNote_MissingFields(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
body := []byte(`{"guest_id": "vm-100"}`)
req := httptest.NewRequest(http.MethodPost, "/api/ai/knowledge/delete", bytes.NewReader(body))
rec := httptest.NewRecorder()
handler.HandleDeleteGuestNote(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rec.Code)
}
}
// ========================================
// HandleClearGuestKnowledge tests
// ========================================
func TestHandleClearGuestKnowledge_MethodNotAllowed(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodGet, "/api/ai/knowledge/clear", nil)
rec := httptest.NewRecorder()
handler.HandleClearGuestKnowledge(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code)
}
}
func TestHandleClearGuestKnowledge_MissingGuestID(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
body := []byte(`{}`)
req := httptest.NewRequest(http.MethodPost, "/api/ai/knowledge/clear", bytes.NewReader(body))
rec := httptest.NewRecorder()
handler.HandleClearGuestKnowledge(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rec.Code)
}
}
// ========================================
// HandleDebugContext tests
// ========================================
func TestHandleDebugContext_MethodNotAllowed(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodPost, "/api/ai/debug/context", nil)
rec := httptest.NewRecorder()
handler.HandleDebugContext(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code)
}
}
// ========================================
// HandleGetConnectedAgents tests
// ========================================
func TestHandleGetConnectedAgents_MethodNotAllowed(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodPost, "/api/ai/agents", nil)
rec := httptest.NewRecorder()
handler.HandleGetConnectedAgents(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code)
}
}
func TestHandleGetConnectedAgents_NoAgentServer(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
// handler created with nil agentServer
handler := newTestAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodGet, "/api/ai/agents", nil)
rec := httptest.NewRecorder()
handler.HandleGetConnectedAgents(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)
}
var resp struct {
Count int `json:"count"`
Note string `json:"note"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Count != 0 {
t.Fatalf("expected count=0, got %d", resp.Count)
}
if resp.Note == "" {
t.Fatalf("expected note to be present")
}
}
// ========================================
// HandleRunCommand tests
// ========================================
func TestHandleRunCommand_MethodNotAllowed(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodGet, "/api/ai/run-command", nil)
rec := httptest.NewRecorder()
handler.HandleRunCommand(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code)
}
}
func TestHandleRunCommand_InvalidBody(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodPost, "/api/ai/run-command", bytes.NewReader([]byte(`{invalid json}`)))
rec := httptest.NewRecorder()
handler.HandleRunCommand(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rec.Code)
}
}
// ========================================
// HandleAnalyzeKubernetesCluster tests
// ========================================
func TestHandleAnalyzeKubernetesCluster_MethodNotAllowed(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodGet, "/api/ai/kubernetes/analyze", nil)
rec := httptest.NewRecorder()
handler.HandleAnalyzeKubernetesCluster(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code)
}
}
func TestHandleAnalyzeKubernetesCluster_InvalidBody(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodPost, "/api/ai/kubernetes/analyze", bytes.NewReader([]byte(`{invalid json}`)))
rec := httptest.NewRecorder()
handler.HandleAnalyzeKubernetesCluster(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rec.Code)
}
}
// ========================================
// HandleInvestigateAlert tests
// ========================================
func TestHandleInvestigateAlert_MethodNotAllowed(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodGet, "/api/ai/investigate", nil)
rec := httptest.NewRecorder()
handler.HandleInvestigateAlert(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code)
}
}
func TestHandleInvestigateAlert_InvalidBody(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
req := httptest.NewRequest(http.MethodPost, "/api/ai/investigate", bytes.NewReader([]byte(`{invalid json}`)))
rec := httptest.NewRecorder()
handler.HandleInvestigateAlert(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rec.Code)
}
}
func TestHandleInvestigateAlert_MissingAlertID(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
body := []byte(`{}`)
req := httptest.NewRequest(http.MethodPost, "/api/ai/investigate", bytes.NewReader(body))
rec := httptest.NewRecorder()
handler.HandleInvestigateAlert(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected status %d, got %d", http.StatusBadRequest, rec.Code)
}
}
// ========================================
// AISettingsHandler setter method tests
// ========================================
func TestAISettingsHandler_SetConfig(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
// SetConfig with nil should be a no-op
handler.SetConfig(nil)
// SetConfig with new config should update the handler's config
newCfg := &config.Config{DataPath: tmp}
handler.SetConfig(newCfg)
// No assertion needed - just verifying it doesn't panic
}
func TestAISettingsHandler_StopPatrol(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
// StopPatrol should be safe to call even when patrol is not running
handler.StopPatrol()
// No assertion needed - just verifying it doesn't panic
}
func TestAISettingsHandler_GetAlertTriggeredAnalyzer(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
// Should return the analyzer (may be nil if not initialized)
analyzer := handler.GetAlertTriggeredAnalyzer(context.Background())
// Just verify it doesn't panic and returns something
_ = analyzer
}
func TestAISettingsHandler_StartPatrol(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
// Start patrol with a cancellable context
ctx, cancel := context.WithCancel(context.Background())
handler.StartPatrol(ctx)
// Give it a brief moment then stop
time.Sleep(10 * time.Millisecond)
cancel()
handler.StopPatrol()
}
func TestAISettingsHandler_SetPatrolFindingsPersistence(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
// Set nil persistence should not panic
err := handler.SetPatrolFindingsPersistence(nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestAISettingsHandler_SetPatrolRunHistoryPersistence(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
// Set nil persistence should not panic
err := handler.SetPatrolRunHistoryPersistence(nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestAISettingsHandler_SetPatrolThresholdProvider(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
// Set nil threshold provider should not panic
handler.SetPatrolThresholdProvider(nil)
}
func TestAISettingsHandler_SetMetricsHistoryProvider(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
// Set nil metrics provider should not panic
handler.SetMetricsHistoryProvider(nil)
}
func TestAISettingsHandler_SetBaselineStore(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
// Set nil baseline store should not panic
handler.SetBaselineStore(nil)
}
func TestAISettingsHandler_SetChangeDetector(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
// Set nil change detector should not panic
handler.SetChangeDetector(nil)
}
func TestAISettingsHandler_SetRemediationLog(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
// Set nil remediation log should not panic
handler.SetRemediationLog(nil)
}
func TestAISettingsHandler_SetPatternDetector(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
// Set nil pattern detector should not panic
handler.SetPatternDetector(nil)
}
func TestAISettingsHandler_SetCorrelationDetector(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
// Set nil correlation detector should not panic
handler.SetCorrelationDetector(nil)
}
func TestAISettingsHandler_Approvals(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
// Initialize approval store
approvalStore, _ := approval.NewStore(approval.StoreConfig{
DataDir: tmp,
DisablePersistence: true,
})
approval.SetStore(approvalStore)
appID := "app-123"
approvalStore.CreateApproval(&approval.ApprovalRequest{
ID: appID,
Command: "ls -la",
Status: approval.StatusPending,
})
t.Run("HandleGetApproval", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/ai/approvals/"+appID, nil)
rec := httptest.NewRecorder()
handler.HandleGetApproval(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
var resp approval.ApprovalRequest
err := json.Unmarshal(rec.Body.Bytes(), &resp)
require.NoError(t, err)
assert.Equal(t, appID, resp.ID)
})
t.Run("HandleApproveCommand", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/ai/approvals/"+appID+"/approve", nil)
rec := httptest.NewRecorder()
handler.HandleApproveCommand(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
app, _ := approvalStore.GetApproval(appID)
assert.Equal(t, approval.StatusApproved, app.Status)
})
t.Run("HandleDenyCommand", func(t *testing.T) {
appID2 := "app-456"
approvalStore.CreateApproval(&approval.ApprovalRequest{
ID: appID2,
Command: "rm -rf /",
Status: approval.StatusPending,
})
body, _ := json.Marshal(map[string]string{"reason": "too dangerous"})
req := httptest.NewRequest(http.MethodPost, "/api/ai/approvals/"+appID2+"/deny", bytes.NewReader(body))
rec := httptest.NewRecorder()
handler.HandleDenyCommand(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
app, _ := approvalStore.GetApproval(appID2)
assert.Equal(t, approval.StatusDenied, app.Status)
assert.Equal(t, "too dangerous", app.DenyReason)
})
}
func TestAISettingsHandler_ChatSessions(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
handler := newTestAISettingsHandler(cfg, persistence, nil)
t.Run("HandleSaveAndGetSession", func(t *testing.T) {
sessionID := "sess-123"
body, _ := json.Marshal(map[string]interface{}{
"id": sessionID,
"title": "Test Session",
})
req := httptest.NewRequest(http.MethodPut, "/api/ai/chat/sessions/"+sessionID, bytes.NewReader(body))
rec := httptest.NewRecorder()
handler.HandleSaveAIChatSession(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
// GET session
req = httptest.NewRequest(http.MethodGet, "/api/ai/chat/sessions/"+sessionID, nil)
rec = httptest.NewRecorder()
handler.HandleGetAIChatSession(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
})
t.Run("HandleListSessions", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/ai/chat/sessions", nil)
rec := httptest.NewRecorder()
handler.HandleListAIChatSessions(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
})
t.Run("HandleDeleteSession", func(t *testing.T) {
sessionID := "sess-delete"
body, _ := json.Marshal(map[string]interface{}{"id": sessionID})
handler.HandleSaveAIChatSession(httptest.NewRecorder(), httptest.NewRequest(http.MethodPut, "/api/ai/chat/sessions/"+sessionID, bytes.NewReader(body)))
req := httptest.NewRequest(http.MethodDelete, "/api/ai/chat/sessions/"+sessionID, nil)
rec := httptest.NewRecorder()
handler.HandleDeleteAIChatSession(rec, req)
assert.Equal(t, http.StatusNoContent, rec.Code)
})
}
func TestShouldRestartAIChat(t *testing.T) {
t.Parallel()
tests := []struct {
name string
req AISettingsUpdateRequest
want bool
}{
{
name: "patrol model change restarts chat",
req: AISettingsUpdateRequest{PatrolModel: ptr("openai:gpt-4o-mini")},
want: true,
},
{
name: "openai base url change restarts chat",
req: AISettingsUpdateRequest{OpenAIBaseURL: ptr("http://localhost:11011")},
want: true,
},
{
name: "provider credential change restarts chat",
req: AISettingsUpdateRequest{OpenAIAPIKey: ptr("test-key")},
want: true,
},
{
name: "credential clear restarts chat",
req: AISettingsUpdateRequest{ClearOpenAIKey: ptr(true)},
want: true,
},
{
name: "patrol interval does not restart chat",
req: AISettingsUpdateRequest{PatrolIntervalMinutes: ptr(60)},
want: false,
},
{
name: "alert analysis toggle does not restart chat",
req: AISettingsUpdateRequest{AlertTriggeredAnalysis: ptr(true)},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := shouldRestartAIChat(tt.req); got != tt.want {
t.Fatalf("shouldRestartAIChat() = %v, want %v", got, tt.want)
}
})
}
}
func TestAISettingsHandler_UpdateSettingsTriggersChatRestartForPatrolModel(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
initial := config.NewDefaultAIConfig()
initial.Enabled = true
initial.Model = "ollama:llama3"
initial.ChatModel = "ollama:llama3"
initial.PatrolModel = "ollama:llama3"
initial.OllamaBaseURL = "http://localhost:11434"
require.NoError(t, persistence.SaveAIConfig(*initial))
handler := newTestAISettingsHandler(cfg, persistence, nil)
restarts := 0
handler.SetOnModelChange(func() {
restarts++
})
body, _ := json.Marshal(AISettingsUpdateRequest{
PatrolModel: ptr("ollama:phi4"),
})
req := httptest.NewRequest(http.MethodPut, "/api/settings/ai", bytes.NewReader(body))
rec := httptest.NewRecorder()
handler.HandleUpdateAISettings(rec, req)
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
require.Equal(t, 1, restarts, "expected patrol model change to restart chat service")
}
func TestAISettingsHandler_UpdateSettingsTriggersChatRestartForProviderEndpoint(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
initial := config.NewDefaultAIConfig()
initial.Enabled = true
initial.Model = "openai:qwen3-omni"
initial.ChatModel = "openai:qwen3-omni"
initial.OpenAIAPIKey = "key-1"
initial.OpenAIBaseURL = "http://localhost:11011"
require.NoError(t, persistence.SaveAIConfig(*initial))
handler := newTestAISettingsHandler(cfg, persistence, nil)
restarts := 0
handler.SetOnModelChange(func() {
restarts++
})
body, _ := json.Marshal(AISettingsUpdateRequest{
OpenAIBaseURL: ptr("http://localhost:22022"),
})
req := httptest.NewRequest(http.MethodPut, "/api/settings/ai", bytes.NewReader(body))
rec := httptest.NewRecorder()
handler.HandleUpdateAISettings(rec, req)
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
require.Equal(t, 1, restarts, "expected provider endpoint change to restart chat service")
}
func TestAISettingsHandler_UpdateSettingsDoesNotTriggerChatRestartForPatrolSchedule(t *testing.T) {
t.Parallel()
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
persistence := config.NewConfigPersistence(tmp)
initial := config.NewDefaultAIConfig()
initial.Enabled = true
initial.Model = "ollama:llama3"
initial.ChatModel = "ollama:llama3"
initial.PatrolModel = "ollama:llama3"
initial.OllamaBaseURL = "http://localhost:11434"
require.NoError(t, persistence.SaveAIConfig(*initial))
handler := newTestAISettingsHandler(cfg, persistence, nil)
restarts := 0
handler.SetOnModelChange(func() {
restarts++
})
body, _ := json.Marshal(AISettingsUpdateRequest{
PatrolIntervalMinutes: ptr(120),
})
req := httptest.NewRequest(http.MethodPut, "/api/settings/ai", bytes.NewReader(body))
rec := httptest.NewRecorder()
handler.HandleUpdateAISettings(rec, req)
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
require.Equal(t, 0, restarts, "expected patrol schedule change to avoid chat restart")
}