diff --git a/internal/api/ai_intelligence_handlers_more_test.go b/internal/api/ai_intelligence_handlers_more_test.go new file mode 100644 index 000000000..b65654ed0 --- /dev/null +++ b/internal/api/ai_intelligence_handlers_more_test.go @@ -0,0 +1,212 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/ai" + "github.com/rcourtman/pulse-go-rewrite/internal/ai/learning" + "github.com/rcourtman/pulse-go-rewrite/internal/ai/unified" + "github.com/rcourtman/pulse-go-rewrite/internal/models" +) + +type snapshotStateProvider struct { + state models.StateSnapshot +} + +func (s snapshotStateProvider) GetState() models.StateSnapshot { + return s.state +} + +func buildBaselineStore(t *testing.T) *ai.BaselineStore { + t.Helper() + store := ai.NewBaselineStore(ai.BaselineConfig{MinSamples: 1}) + points := []ai.BaselineMetricPoint{{Value: 10, Timestamp: time.Now()}} + if err := store.Learn("vm-1", "vm", "cpu", points); err != nil { + t.Fatalf("baseline Learn error: %v", err) + } + return store +} + +func TestHandleGetRecentChanges_WithDetector(t *testing.T) { + svc := newEnabledAIService(t) + detector := ai.NewChangeDetector(ai.ChangeDetectorConfig{MaxChanges: 10}) + change := ai.Change{ + ID: "change-1", + ResourceID: "vm-1", + ResourceName: "vm-one", + ResourceType: "vm", + ChangeType: ai.ChangeConfig, + Before: "old", + After: "new", + DetectedAt: time.Now().Add(-30 * time.Minute), + Description: "updated config", + } + setUnexportedField(t, detector, "changes", []ai.Change{change}) + svc.SetChangeDetector(detector) + + handler := &AISettingsHandler{legacyAIService: svc} + req := httptest.NewRequest(http.MethodGet, "/api/ai/intelligence/changes?hours=1", nil) + rec := httptest.NewRecorder() + + handler.HandleGetRecentChanges(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + var payload map[string]interface{} + if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if payload["count"] != float64(1) { + t.Fatalf("expected count 1, got %#v", payload["count"]) + } + changes, _ := payload["changes"].([]interface{}) + if len(changes) != 1 { + t.Fatalf("expected 1 change, got %d", len(changes)) + } +} + +func TestHandleGetBaselines_WithStore(t *testing.T) { + svc := newEnabledAIService(t) + store := buildBaselineStore(t) + svc.SetBaselineStore(store) + + handler := &AISettingsHandler{legacyAIService: svc} + req := httptest.NewRequest(http.MethodGet, "/api/ai/intelligence/baselines?resource_id=vm-1", nil) + rec := httptest.NewRecorder() + + handler.HandleGetBaselines(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + var payload map[string]interface{} + if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if payload["count"] != float64(1) { + t.Fatalf("expected count 1, got %#v", payload["count"]) + } +} + +func TestHandleGetLearningStatus_WithBaselines(t *testing.T) { + svc := newEnabledAIService(t) + store := buildBaselineStore(t) + svc.SetBaselineStore(store) + + handler := &AISettingsHandler{legacyAIService: svc} + req := httptest.NewRequest(http.MethodGet, "/api/ai/intelligence/learning", nil) + rec := httptest.NewRecorder() + + handler.HandleGetLearningStatus(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + var payload map[string]interface{} + if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if payload["resources_baselined"] != float64(1) { + t.Fatalf("expected resources_baselined 1, got %#v", payload["resources_baselined"]) + } + if payload["status"] != "learning" { + t.Fatalf("expected status learning, got %#v", payload["status"]) + } +} + +func TestHandleGetAnomalies_WithBaseline(t *testing.T) { + svc := newEnabledAIService(t) + store := buildBaselineStore(t) + svc.SetBaselineStore(store) + + state := models.StateSnapshot{ + VMs: []models.VM{{ + ID: "vm-1", + Name: "vm-one", + Status: "running", + CPU: 0.8, + Memory: models.Memory{Usage: 50}, + }}, + } + svc.SetStateProvider(snapshotStateProvider{state: state}) + + handler := &AISettingsHandler{legacyAIService: svc} + req := httptest.NewRequest(http.MethodGet, "/api/ai/intelligence/anomalies?resource_id=vm-1", nil) + rec := httptest.NewRecorder() + + handler.HandleGetAnomalies(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + var payload map[string]interface{} + if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if payload["count"] == float64(0) { + t.Fatalf("expected anomalies to be returned") + } +} + +func TestHandleGetLearningPreferences_WithStore(t *testing.T) { + store := learning.NewLearningStore(learning.LearningStoreConfig{}) + handler := &AISettingsHandler{} + handler.SetLearningStore(store) + + req := httptest.NewRequest(http.MethodGet, "/api/ai/learning/preferences?resource_id=vm-1", nil) + rec := httptest.NewRecorder() + + handler.HandleGetLearningPreferences(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + var payload map[string]interface{} + if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if payload["resource_id"] != "vm-1" { + t.Fatalf("expected resource_id in response, got %#v", payload["resource_id"]) + } +} + +func TestHandleGetUnifiedFindings_WithStore(t *testing.T) { + store := unified.NewUnifiedStore(unified.DefaultAlertToFindingConfig()) + store.AddFromAI(&unified.UnifiedFinding{ + ID: "finding-1", + Source: unified.SourceAIPatrol, + Severity: unified.SeverityCritical, + Category: unified.CategoryPerformance, + ResourceID: "vm-1", + ResourceName: "vm-one", + ResourceType: "vm", + Title: "CPU high", + Description: "cpu usage high", + DetectedAt: time.Now(), + LastSeenAt: time.Now(), + }) + + handler := &AISettingsHandler{} + handler.SetUnifiedStore(store) + + req := httptest.NewRequest(http.MethodGet, "/api/ai/unified/findings?resource_id=vm-1", nil) + rec := httptest.NewRecorder() + + handler.HandleGetUnifiedFindings(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + var payload map[string]interface{} + if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if payload["count"] == float64(0) { + t.Fatalf("expected findings in response") + } +} diff --git a/internal/api/ai_intelligence_handlers_remediation_more_test.go b/internal/api/ai_intelligence_handlers_remediation_more_test.go new file mode 100644 index 000000000..aab55c047 --- /dev/null +++ b/internal/api/ai_intelligence_handlers_remediation_more_test.go @@ -0,0 +1,213 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/ai" + "github.com/rcourtman/pulse-go-rewrite/internal/ai/remediation" + "github.com/rcourtman/pulse-go-rewrite/internal/license" +) + +func TestHandleGetRemediations_WithLog(t *testing.T) { + svc := newEnabledAIService(t) + log := ai.NewRemediationLog(ai.RemediationLogConfig{MaxRecords: 10}) + record := ai.RemediationRecord{ + ResourceID: "vm-1", + ResourceType: "vm", + ResourceName: "vm-one", + FindingID: "finding-1", + Problem: "cpu high", + Action: "restart", + Outcome: ai.OutcomeResolved, + Automatic: true, + Timestamp: time.Now(), + Duration: 2 * time.Second, + } + if err := log.Log(record); err != nil { + t.Fatalf("log remediation: %v", err) + } + + patrol := svc.GetPatrolService() + setUnexportedField(t, patrol, "remediationLog", log) + + handler := &AISettingsHandler{legacyAIService: svc} + req := httptest.NewRequest(http.MethodGet, "/api/ai/intelligence/remediations?hours=1", nil) + rec := httptest.NewRecorder() + + handler.HandleGetRemediations(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) + } + + var payload map[string]interface{} + if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if payload["count"] != float64(1) { + t.Fatalf("expected count 1, got %#v", payload["count"]) + } + stats := payload["stats"].(map[string]interface{}) + if stats["resolved"] != float64(1) || stats["automatic"] != float64(1) { + t.Fatalf("unexpected stats: %#v", stats) + } +} + +func TestHandleGetRemediations_FilterFinding(t *testing.T) { + svc := newEnabledAIService(t) + log := ai.NewRemediationLog(ai.RemediationLogConfig{MaxRecords: 10}) + record := ai.RemediationRecord{ + ResourceID: "vm-2", + ResourceType: "vm", + ResourceName: "vm-two", + FindingID: "finding-2", + Problem: "disk", + Action: "cleanup", + Outcome: ai.OutcomePartial, + Automatic: false, + Timestamp: time.Now(), + } + if err := log.Log(record); err != nil { + t.Fatalf("log remediation: %v", err) + } + + patrol := svc.GetPatrolService() + setUnexportedField(t, patrol, "remediationLog", log) + + handler := &AISettingsHandler{legacyAIService: svc} + req := httptest.NewRequest(http.MethodGet, "/api/ai/intelligence/remediations?finding_id=finding-2", nil) + rec := httptest.NewRecorder() + + handler.HandleGetRemediations(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) + } + var payload map[string]interface{} + if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if payload["count"] != float64(1) { + t.Fatalf("expected count 1, got %#v", payload["count"]) + } +} + +func TestHandleGetRemediations_LicenseHeader(t *testing.T) { + handler := &AISettingsHandler{} + req := httptest.NewRequest(http.MethodGet, "/api/ai/intelligence/remediations", nil) + rec := httptest.NewRecorder() + + handler.HandleGetRemediations(rec, req) + + if rec.Header().Get("X-License-Required") != "true" { + t.Fatalf("expected license header to be set") + } + if rec.Header().Get("X-License-Feature") != license.FeatureAIAutoFix { + t.Fatalf("expected license feature header") + } +} + +func TestHandleGetRemediationPlans_StatusMapping(t *testing.T) { + engine := remediation.NewEngine(remediation.EngineConfig{}) + plan := &remediation.RemediationPlan{ + ID: "plan-1", + Title: "Critical fix", + Description: "fix it", + RiskLevel: remediation.RiskCritical, + Steps: []remediation.RemediationStep{ + {Order: 0, Command: "echo ok"}, + }, + } + if err := engine.CreatePlan(plan); err != nil { + t.Fatalf("CreatePlan: %v", err) + } + if _, err := engine.ApprovePlan(plan.ID, "tester"); err != nil { + t.Fatalf("ApprovePlan: %v", err) + } + + handler := &AISettingsHandler{} + handler.SetRemediationEngine(engine) + + req := httptest.NewRequest(http.MethodGet, "/api/ai/remediation/plans?limit=5", nil) + rec := httptest.NewRecorder() + handler.HandleGetRemediationPlans(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) + } + + var payload map[string]interface{} + if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + plans := payload["plans"].([]interface{}) + if len(plans) != 1 { + t.Fatalf("expected 1 plan, got %d", len(plans)) + } + planView := plans[0].(map[string]interface{}) + if planView["risk_level"] != string(remediation.RiskHigh) { + t.Fatalf("expected risk_level high, got %#v", planView["risk_level"]) + } + if planView["status"] != "approved" { + t.Fatalf("expected status approved, got %#v", planView["status"]) + } +} + +func TestHandleGetRemediationPlan_Success(t *testing.T) { + engine := remediation.NewEngine(remediation.EngineConfig{}) + plan := &remediation.RemediationPlan{ + ID: "plan-2", + Title: "Fix", + Steps: []remediation.RemediationStep{{Order: 0, Command: "echo ok"}}, + } + if err := engine.CreatePlan(plan); err != nil { + t.Fatalf("CreatePlan: %v", err) + } + + handler := &AISettingsHandler{} + handler.SetRemediationEngine(engine) + + req := httptest.NewRequest(http.MethodGet, "/api/ai/remediation/plans/plan-2?plan_id=plan-2", nil) + rec := httptest.NewRecorder() + handler.HandleGetRemediationPlan(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) + } + var got remediation.RemediationPlan + if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { + t.Fatalf("decode response: %v", err) + } + if got.ID != "plan-2" { + t.Fatalf("unexpected plan id: %s", got.ID) + } +} + +func TestHandleApproveRemediationPlan_Success(t *testing.T) { + engine := remediation.NewEngine(remediation.EngineConfig{}) + plan := &remediation.RemediationPlan{ + ID: "plan-3", + Title: "Approve", + Steps: []remediation.RemediationStep{{Order: 0, Command: "echo ok"}}, + } + if err := engine.CreatePlan(plan); err != nil { + t.Fatalf("CreatePlan: %v", err) + } + + handler := &AISettingsHandler{} + handler.SetRemediationEngine(engine) + + body := []byte(`{"plan_id":"plan-3"}`) + req := httptest.NewRequest(http.MethodPost, "/api/ai/remediation/plans/plan-3/approve", bytes.NewReader(body)) + rec := httptest.NewRecorder() + handler.HandleApproveRemediationPlan(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) + } +} diff --git a/internal/api/docker_agents_error_test.go b/internal/api/docker_agents_error_test.go new file mode 100644 index 000000000..46a7ffb4f --- /dev/null +++ b/internal/api/docker_agents_error_test.go @@ -0,0 +1,98 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" +) + +func TestDockerAgentHandlers_SetMonitorAndTenant(t *testing.T) { + handler := &DockerAgentHandlers{} + monitor := &monitoring.Monitor{} + handler.SetMonitor(monitor) + if handler.legacyMonitor != monitor { + t.Fatalf("expected legacy monitor to be set") + } + + mtm := &monitoring.MultiTenantMonitor{} + setUnexportedField(t, mtm, "monitors", map[string]*monitoring.Monitor{ + "default": monitor, + }) + + handler.SetMultiTenantMonitor(mtm) + if handler.mtMonitor != mtm { + t.Fatalf("expected multi-tenant monitor to be set") + } + if handler.legacyMonitor != monitor { + t.Fatalf("expected legacy monitor to be set from multi-tenant default") + } +} + +func TestDockerAgentHandlers_GetMonitorFallback(t *testing.T) { + legacy := &monitoring.Monitor{} + handler := &DockerAgentHandlers{legacyMonitor: legacy} + + if got := handler.getMonitor(context.Background()); got != legacy { + t.Fatalf("expected legacy monitor fallback") + } +} + +func TestDockerAgentHandlers_HandleReport_Errors(t *testing.T) { + handler := &DockerAgentHandlers{} + + req := httptest.NewRequest(http.MethodGet, "/api/agents/docker/report", nil) + rec := httptest.NewRecorder() + handler.HandleReport(rec, req) + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected 405, got %d", rec.Code) + } + + req = httptest.NewRequest(http.MethodPost, "/api/agents/docker/report", bytes.NewReader([]byte("{bad"))) + rec = httptest.NewRecorder() + handler.HandleReport(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rec.Code) + } +} + +func TestDockerAgentHandlers_HandleCommandAck_Errors(t *testing.T) { + handler := &DockerAgentHandlers{} + + req := httptest.NewRequest(http.MethodPost, "/api/agents/docker/commands/123", nil) + rec := httptest.NewRecorder() + handler.HandleCommandAck(rec, req) + if rec.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", rec.Code) + } + + req = httptest.NewRequest(http.MethodPost, "/api/agents/docker/commands//ack", nil) + rec = httptest.NewRecorder() + handler.HandleCommandAck(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rec.Code) + } + + req = httptest.NewRequest(http.MethodPost, "/api/agents/docker/commands/cmd-1/ack", bytes.NewReader([]byte("{bad"))) + rec = httptest.NewRecorder() + handler.HandleCommandAck(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rec.Code) + } + + payload := map[string]string{ + "hostId": "host-1", + "status": "unknown", + } + body, _ := json.Marshal(payload) + req = httptest.NewRequest(http.MethodPost, "/api/agents/docker/commands/cmd-2/ack", bytes.NewReader(body)) + rec = httptest.NewRecorder() + handler.HandleCommandAck(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rec.Code) + } +} diff --git a/internal/api/docker_agents_routes_more_test.go b/internal/api/docker_agents_routes_more_test.go new file mode 100644 index 000000000..26441a16d --- /dev/null +++ b/internal/api/docker_agents_routes_more_test.go @@ -0,0 +1,123 @@ +package api + +import ( + "bytes" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" +) + +func TestNewDockerAgentHandlers_DefaultMonitorFromMultiTenant(t *testing.T) { + monitor := &monitoring.Monitor{} + mtm := &monitoring.MultiTenantMonitor{} + setUnexportedField(t, mtm, "monitors", map[string]*monitoring.Monitor{ + "default": monitor, + }) + + handler := NewDockerAgentHandlers(mtm, nil, nil, nil) + if handler.legacyMonitor != monitor { + t.Fatalf("expected legacy monitor to be set from multi-tenant default") + } +} + +func TestDockerAgentHandlers_HandleDockerHostActions_Routes(t *testing.T) { + handler, monitor := newDockerAgentHandlers(t, nil) + hostID := seedDockerHost(t, monitor) + + req := httptest.NewRequest(http.MethodPost, "/api/agents/docker/hosts/"+hostID+"/allow-reenroll", nil) + rec := httptest.NewRecorder() + handler.HandleDockerHostActions(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("allow-reenroll status = %d, want 200", rec.Code) + } + + req = httptest.NewRequest(http.MethodPut, "/api/agents/docker/hosts/"+hostID+"/unhide", nil) + rec = httptest.NewRecorder() + handler.HandleDockerHostActions(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("unhide status = %d, want 200", rec.Code) + } + + req = httptest.NewRequest(http.MethodPut, "/api/agents/docker/hosts/"+hostID+"/pending-uninstall", nil) + rec = httptest.NewRecorder() + handler.HandleDockerHostActions(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("pending-uninstall status = %d, want 200", rec.Code) + } + + body := []byte(`{"displayName":"New Name"}`) + req = httptest.NewRequest(http.MethodPut, "/api/agents/docker/hosts/"+hostID+"/display-name", bytes.NewReader(body)) + rec = httptest.NewRecorder() + handler.HandleDockerHostActions(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("display-name status = %d, want 200", rec.Code) + } + + req = httptest.NewRequest(http.MethodPost, "/api/agents/docker/hosts/"+hostID+"/check-updates", nil) + rec = httptest.NewRecorder() + handler.HandleDockerHostActions(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("check-updates status = %d, want 200", rec.Code) + } + if !strings.Contains(rec.Body.String(), "Check for updates") { + t.Fatalf("expected check updates message") + } +} + +func TestDockerAgentHandlers_HandleDockerHostActions_DeleteRoute(t *testing.T) { + handler, monitor := newDockerAgentHandlers(t, nil) + hostID := seedDockerHost(t, monitor) + + req := httptest.NewRequest(http.MethodDelete, "/api/agents/docker/hosts/"+hostID, nil) + rec := httptest.NewRecorder() + handler.HandleDockerHostActions(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("delete status = %d, want 200", rec.Code) + } +} + +func TestDockerAgentHandlers_HandleDockerHostActions_MethodNotAllowed(t *testing.T) { + handler := &DockerAgentHandlers{} + + req := httptest.NewRequest(http.MethodGet, "/api/agents/docker/hosts/host-1/unknown", nil) + rec := httptest.NewRecorder() + handler.HandleDockerHostActions(rec, req) + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected 405, got %d", rec.Code) + } +} + +func TestDockerAgentHandlers_HandleDeleteHost_Errors(t *testing.T) { + handler, _ := newDockerAgentHandlers(t, nil) + + req := httptest.NewRequest(http.MethodGet, "/api/agents/docker/hosts/host-1", nil) + rec := httptest.NewRecorder() + handler.HandleDeleteHost(rec, req) + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected 405, got %d", rec.Code) + } + + req = httptest.NewRequest(http.MethodDelete, "/api/agents/docker/hosts/", nil) + rec = httptest.NewRecorder() + handler.HandleDeleteHost(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rec.Code) + } + + req = httptest.NewRequest(http.MethodDelete, "/api/agents/docker/hosts/missing?hide=true", nil) + rec = httptest.NewRecorder() + handler.HandleDeleteHost(rec, req) + if rec.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", rec.Code) + } + + req = httptest.NewRequest(http.MethodDelete, "/api/agents/docker/hosts/missing?force=true", nil) + rec = httptest.NewRecorder() + handler.HandleDeleteHost(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } +} diff --git a/internal/api/kubernetes_agents_error_test.go b/internal/api/kubernetes_agents_error_test.go new file mode 100644 index 000000000..a960d8ee4 --- /dev/null +++ b/internal/api/kubernetes_agents_error_test.go @@ -0,0 +1,104 @@ +package api + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" +) + +func TestKubernetesAgentHandlers_SetMultiTenantMonitor(t *testing.T) { + handler := &KubernetesAgentHandlers{} + monitor := &monitoring.Monitor{} + + mtm := &monitoring.MultiTenantMonitor{} + setUnexportedField(t, mtm, "monitors", map[string]*monitoring.Monitor{ + "default": monitor, + }) + + handler.SetMultiTenantMonitor(mtm) + if handler.mtMonitor != mtm { + t.Fatalf("expected multi-tenant monitor to be set") + } + if handler.legacyMonitor != monitor { + t.Fatalf("expected legacy monitor to be set from multi-tenant default") + } +} + +func TestKubernetesAgentHandlers_HandleReport_Errors(t *testing.T) { + handler := &KubernetesAgentHandlers{} + + req := httptest.NewRequest(http.MethodGet, "/api/agents/kubernetes/report", nil) + rec := httptest.NewRecorder() + handler.HandleReport(rec, req) + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected 405, got %d", rec.Code) + } + + req = httptest.NewRequest(http.MethodPost, "/api/agents/kubernetes/report", bytes.NewReader([]byte("{bad"))) + rec = httptest.NewRecorder() + handler.HandleReport(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rec.Code) + } +} + +func TestKubernetesAgentHandlers_HandleClusterActions_MethodNotAllowed(t *testing.T) { + handler := &KubernetesAgentHandlers{} + + req := httptest.NewRequest(http.MethodGet, "/api/agents/kubernetes/clusters/cluster-1/unknown", nil) + rec := httptest.NewRecorder() + handler.HandleClusterActions(rec, req) + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected 405, got %d", rec.Code) + } +} + +func TestKubernetesAgentHandlers_HandleDeleteCluster_Errors(t *testing.T) { + handler, _ := newKubernetesAgentHandlers(t, nil) + + req := httptest.NewRequest(http.MethodGet, "/api/agents/kubernetes/clusters/cluster-1", nil) + rec := httptest.NewRecorder() + handler.HandleDeleteCluster(rec, req) + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected 405, got %d", rec.Code) + } + + req = httptest.NewRequest(http.MethodDelete, "/api/agents/kubernetes/clusters/", nil) + rec = httptest.NewRecorder() + handler.HandleDeleteCluster(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rec.Code) + } + + req = httptest.NewRequest(http.MethodDelete, "/api/agents/kubernetes/clusters/missing", nil) + rec = httptest.NewRecorder() + handler.HandleDeleteCluster(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } +} + +func TestKubernetesAgentHandlers_HandleAllowReenroll_MissingID(t *testing.T) { + handler := &KubernetesAgentHandlers{} + + req := httptest.NewRequest(http.MethodPost, "/api/agents/kubernetes/clusters//allow-reenroll", nil) + rec := httptest.NewRecorder() + handler.HandleAllowReenroll(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rec.Code) + } +} + +func TestKubernetesAgentHandlers_HandleSetCustomDisplayName_InvalidJSON(t *testing.T) { + handler := &KubernetesAgentHandlers{} + + req := httptest.NewRequest(http.MethodPut, "/api/agents/kubernetes/clusters/cluster-1/display-name", bytes.NewReader([]byte("{bad"))) + rec := httptest.NewRecorder() + handler.HandleSetCustomDisplayName(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rec.Code) + } +} diff --git a/internal/api/log_redact_test.go b/internal/api/log_redact_test.go new file mode 100644 index 000000000..d151231dc --- /dev/null +++ b/internal/api/log_redact_test.go @@ -0,0 +1,18 @@ +package api + +import "testing" + +func TestSafePrefixForLog(t *testing.T) { + if got := safePrefixForLog("value", 0); got != "" { + t.Fatalf("expected empty for n<=0, got %q", got) + } + if got := safePrefixForLog("", 3); got != "" { + t.Fatalf("expected empty for empty value, got %q", got) + } + if got := safePrefixForLog("abc", 3); got != "abc" { + t.Fatalf("expected full value when len<=n, got %q", got) + } + if got := safePrefixForLog("abcdef", 3); got != "abc" { + t.Fatalf("expected prefix, got %q", got) + } +} diff --git a/internal/api/monitor_wrappers_test.go b/internal/api/monitor_wrappers_test.go new file mode 100644 index 000000000..df7b5bb19 --- /dev/null +++ b/internal/api/monitor_wrappers_test.go @@ -0,0 +1,96 @@ +package api + +import ( + "reflect" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/ai/memory" + "github.com/rcourtman/pulse-go-rewrite/internal/alerts" + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/models" + "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" + "github.com/rcourtman/pulse-go-rewrite/internal/notifications" +) + +func TestNewAlertMonitorWrapper_Nil(t *testing.T) { + if NewAlertMonitorWrapper(nil) != nil { + t.Fatalf("expected nil wrapper for nil monitor") + } +} + +func TestNewNotificationMonitorWrapper_Nil(t *testing.T) { + if NewNotificationMonitorWrapper(nil) != nil { + t.Fatalf("expected nil wrapper for nil monitor") + } +} + +func TestAlertMonitorWrapper_Delegates(t *testing.T) { + monitor := &monitoring.Monitor{} + state := models.NewState() + alertManager := &alerts.Manager{} + incidentStore := &memory.IncidentStore{} + notificationMgr := ¬ifications.NotificationManager{} + configPersist := &config.ConfigPersistence{} + + setUnexportedField(t, monitor, "state", state) + setUnexportedField(t, monitor, "alertManager", alertManager) + setUnexportedField(t, monitor, "incidentStore", incidentStore) + setUnexportedField(t, monitor, "notificationMgr", notificationMgr) + setUnexportedField(t, monitor, "configPersist", configPersist) + + wrapper := NewAlertMonitorWrapper(monitor) + if wrapper == nil { + t.Fatalf("expected wrapper for non-nil monitor") + } + + if wrapper.GetAlertManager() != alertManager { + t.Fatalf("unexpected alert manager") + } + if wrapper.GetIncidentStore() != incidentStore { + t.Fatalf("unexpected incident store") + } + if wrapper.GetNotificationManager() != notificationMgr { + t.Fatalf("unexpected notification manager") + } + if wrapper.GetConfigPersistence() != configPersist { + t.Fatalf("unexpected config persistence") + } + + expected := state.GetSnapshot() + if got := wrapper.GetState(); !reflect.DeepEqual(got, expected) { + t.Fatalf("unexpected state snapshot") + } + + wrapper.SyncAlertState() + if state.ActiveAlerts == nil { + t.Fatalf("expected active alerts to be initialized") + } +} + +func TestNotificationMonitorWrapper_Delegates(t *testing.T) { + monitor := &monitoring.Monitor{} + state := models.NewState() + notificationMgr := ¬ifications.NotificationManager{} + configPersist := &config.ConfigPersistence{} + + setUnexportedField(t, monitor, "state", state) + setUnexportedField(t, monitor, "notificationMgr", notificationMgr) + setUnexportedField(t, monitor, "configPersist", configPersist) + + wrapper := NewNotificationMonitorWrapper(monitor) + if wrapper == nil { + t.Fatalf("expected wrapper for non-nil monitor") + } + + if wrapper.GetNotificationManager() != notificationMgr { + t.Fatalf("unexpected notification manager") + } + if wrapper.GetConfigPersistence() != configPersist { + t.Fatalf("unexpected config persistence") + } + + expected := state.GetSnapshot() + if got := wrapper.GetState(); !reflect.DeepEqual(got, expected) { + t.Fatalf("unexpected state snapshot") + } +} diff --git a/internal/api/notification_queue_error_test.go b/internal/api/notification_queue_error_test.go new file mode 100644 index 000000000..de32652c9 --- /dev/null +++ b/internal/api/notification_queue_error_test.go @@ -0,0 +1,134 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" + "github.com/rcourtman/pulse-go-rewrite/internal/notifications" +) + +func TestNotificationQueueHandlers_GetDLQ_MissingScope(t *testing.T) { + handler := &NotificationQueueHandlers{} + + req := httptest.NewRequest(http.MethodGet, "/api/notifications/dlq", nil) + record := &config.APITokenRecord{Scopes: []string{config.ScopeMonitoringWrite}} + attachAPITokenRecord(req, record) + + rec := httptest.NewRecorder() + handler.GetDLQ(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d", rec.Code) + } +} + +func TestNotificationQueueHandlers_GetDLQ_QueueNil(t *testing.T) { + monitor := &monitoring.Monitor{} + setUnexportedField(t, monitor, "notificationMgr", ¬ifications.NotificationManager{}) + handler := NewNotificationQueueHandlers(monitor) + + req := httptest.NewRequest(http.MethodGet, "/api/notifications/dlq", nil) + rec := httptest.NewRecorder() + handler.GetDLQ(rec, req) + + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("expected 503, got %d", rec.Code) + } +} + +func TestNotificationQueueHandlers_GetQueueStats_QueueNil(t *testing.T) { + monitor := &monitoring.Monitor{} + setUnexportedField(t, monitor, "notificationMgr", ¬ifications.NotificationManager{}) + handler := NewNotificationQueueHandlers(monitor) + + req := httptest.NewRequest(http.MethodGet, "/api/notifications/queue/stats", nil) + rec := httptest.NewRecorder() + handler.GetQueueStats(rec, req) + + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("expected 503, got %d", rec.Code) + } +} + +func TestNotificationQueueHandlers_RetryDLQItem_Errors(t *testing.T) { + handler := &NotificationQueueHandlers{} + + req := httptest.NewRequest(http.MethodPost, "/api/notifications/dlq/retry", bytes.NewReader([]byte("{bad"))) + rec := httptest.NewRecorder() + handler.RetryDLQItem(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rec.Code) + } + + req = httptest.NewRequest(http.MethodPost, "/api/notifications/dlq/retry", bytes.NewReader([]byte(`{"id":""}`))) + rec = httptest.NewRecorder() + handler.RetryDLQItem(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rec.Code) + } +} + +func TestNotificationQueueHandlers_RetryDLQItem_QueueNil(t *testing.T) { + monitor := &monitoring.Monitor{} + setUnexportedField(t, monitor, "notificationMgr", ¬ifications.NotificationManager{}) + handler := NewNotificationQueueHandlers(monitor) + + req := httptest.NewRequest(http.MethodPost, "/api/notifications/dlq/retry", bytes.NewReader([]byte(`{"id":"missing"}`))) + rec := httptest.NewRecorder() + handler.RetryDLQItem(rec, req) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("expected 503, got %d", rec.Code) + } +} + +func TestNotificationQueueHandlers_DeleteDLQItem_Errors(t *testing.T) { + handler := &NotificationQueueHandlers{} + + req := httptest.NewRequest(http.MethodPost, "/api/notifications/dlq/delete", bytes.NewReader([]byte("{bad"))) + rec := httptest.NewRecorder() + handler.DeleteDLQItem(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rec.Code) + } + + req = httptest.NewRequest(http.MethodPost, "/api/notifications/dlq/delete", bytes.NewReader([]byte(`{"id":""}`))) + rec = httptest.NewRecorder() + handler.DeleteDLQItem(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d", rec.Code) + } +} + +func TestNotificationQueueHandlers_DeleteDLQItem_QueueNil(t *testing.T) { + monitor := &monitoring.Monitor{} + setUnexportedField(t, monitor, "notificationMgr", ¬ifications.NotificationManager{}) + handler := NewNotificationQueueHandlers(monitor) + + req := httptest.NewRequest(http.MethodPost, "/api/notifications/dlq/delete", bytes.NewReader([]byte(`{"id":"missing"}`))) + rec := httptest.NewRecorder() + handler.DeleteDLQItem(rec, req) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("expected 503, got %d", rec.Code) + } +} + +func TestNotificationQueueHandlers_GetDLQ_InvalidLimit(t *testing.T) { + handler, _ := newNotificationQueueHandlers(t) + + req := httptest.NewRequest(http.MethodGet, "/api/notifications/dlq?limit=invalid", nil) + rec := httptest.NewRecorder() + handler.GetDLQ(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + + var dlq []notifications.QueuedNotification + if err := json.Unmarshal(rec.Body.Bytes(), &dlq); err != nil { + t.Fatalf("decode dlq: %v", err) + } +} diff --git a/internal/api/router_handlers_additional_test.go b/internal/api/router_handlers_additional_test.go new file mode 100644 index 000000000..4d760599a --- /dev/null +++ b/internal/api/router_handlers_additional_test.go @@ -0,0 +1,389 @@ +package api + +import ( + "crypto/sha256" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/models" +) + +func TestHandleConfig_MethodNotAllowed(t *testing.T) { + router := &Router{config: &config.Config{}} + req := httptest.NewRequest(http.MethodPost, "/api/config", nil) + rec := httptest.NewRecorder() + + router.handleConfig(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code) + } +} + +func TestHandleConfig_Success(t *testing.T) { + router := &Router{config: &config.Config{AutoUpdateEnabled: true, UpdateChannel: "beta"}} + req := httptest.NewRequest(http.MethodGet, "/api/config", nil) + rec := httptest.NewRecorder() + + router.handleConfig(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) + } + var payload map[string]interface{} + if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if payload["csrfProtection"] != false { + t.Fatalf("expected csrfProtection=false, got %#v", payload["csrfProtection"]) + } + if payload["autoUpdateEnabled"] != true { + t.Fatalf("expected autoUpdateEnabled=true, got %#v", payload["autoUpdateEnabled"]) + } + if payload["updateChannel"] != "beta" { + t.Fatalf("expected updateChannel=beta, got %#v", payload["updateChannel"]) + } +} + +func TestHandleBackups_MethodNotAllowed(t *testing.T) { + router := &Router{monitor: nil} + req := httptest.NewRequest(http.MethodPost, "/api/backups", nil) + rec := httptest.NewRecorder() + + router.handleBackups(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code) + } +} + +func TestHandleBackups_Success(t *testing.T) { + monitor, state, _ := newTestMonitor(t) + state.PVEBackups = models.PVEBackups{ + BackupTasks: []models.BackupTask{{ID: "task-2"}}, + StorageBackups: []models.StorageBackup{{ID: "storage-1"}}, + GuestSnapshots: []models.GuestSnapshot{{ID: "snap-1"}}, + } + state.PBSBackups = []models.PBSBackup{{ID: "pbs-1"}} + state.PMGBackups = []models.PMGBackup{{ID: "pmg-1"}} + + router := &Router{monitor: monitor} + req := httptest.NewRequest(http.MethodGet, "/api/backups", nil) + rec := httptest.NewRecorder() + + router.handleBackups(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) + } + var payload struct { + Backups models.Backups `json:"backups"` + PVEBackups models.PVEBackups `json:"pveBackups"` + PBSBackups []models.PBSBackup `json:"pbsBackups"` + PMGBackups []models.PMGBackup `json:"pmgBackups"` + BackupTasks []models.BackupTask `json:"backupTasks"` + Storage []models.StorageBackup `json:"storageBackups"` + GuestSnaps []models.GuestSnapshot `json:"guestSnapshots"` + } + if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(payload.Backups.PBS) != 1 || payload.Backups.PBS[0].ID != "pbs-1" { + t.Fatalf("unexpected backups PBS data: %#v", payload.Backups.PBS) + } + if len(payload.PVEBackups.BackupTasks) != 1 || payload.PVEBackups.BackupTasks[0].ID != "task-2" { + t.Fatalf("unexpected pveBackups data: %#v", payload.PVEBackups.BackupTasks) + } + if len(payload.BackupTasks) != 1 || payload.BackupTasks[0].ID != "task-2" { + t.Fatalf("unexpected backupTasks data: %#v", payload.BackupTasks) + } + if len(payload.Storage) != 1 || payload.Storage[0].ID != "storage-1" { + t.Fatalf("unexpected storageBackups data: %#v", payload.Storage) + } + if len(payload.GuestSnaps) != 1 || payload.GuestSnaps[0].ID != "snap-1" { + t.Fatalf("unexpected guestSnapshots data: %#v", payload.GuestSnaps) + } + if len(payload.PBSBackups) != 1 || payload.PBSBackups[0].ID != "pbs-1" { + t.Fatalf("unexpected pbsBackups data: %#v", payload.PBSBackups) + } + if len(payload.PMGBackups) != 1 || payload.PMGBackups[0].ID != "pmg-1" { + t.Fatalf("unexpected pmgBackups data: %#v", payload.PMGBackups) + } +} + +func TestHandleBackupsPVE_Empty(t *testing.T) { + monitor, state, _ := newTestMonitor(t) + state.PVEBackups = models.PVEBackups{} + + router := &Router{monitor: monitor} + req := httptest.NewRequest(http.MethodGet, "/api/backups/pve", nil) + rec := httptest.NewRecorder() + + router.handleBackupsPVE(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) + } + var payload struct { + Backups []models.StorageBackup `json:"backups"` + } + if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if payload.Backups == nil || len(payload.Backups) != 0 { + t.Fatalf("expected empty backups array, got %#v", payload.Backups) + } +} + +func TestHandleBackupsPBS_Empty(t *testing.T) { + monitor, state, _ := newTestMonitor(t) + state.PBSInstances = nil + + router := &Router{monitor: monitor} + req := httptest.NewRequest(http.MethodGet, "/api/backups/pbs", nil) + rec := httptest.NewRecorder() + + router.handleBackupsPBS(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) + } + var payload struct { + Instances []models.PBSInstance `json:"instances"` + } + if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if payload.Instances == nil || len(payload.Instances) != 0 { + t.Fatalf("expected empty instances array, got %#v", payload.Instances) + } +} + +func TestHandleSnapshots_Empty(t *testing.T) { + monitor, state, _ := newTestMonitor(t) + state.PVEBackups = models.PVEBackups{} + + router := &Router{monitor: monitor} + req := httptest.NewRequest(http.MethodGet, "/api/snapshots", nil) + rec := httptest.NewRecorder() + + router.handleSnapshots(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) + } + var payload struct { + Snapshots []models.GuestSnapshot `json:"snapshots"` + } + if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if payload.Snapshots == nil || len(payload.Snapshots) != 0 { + t.Fatalf("expected empty snapshots array, got %#v", payload.Snapshots) + } +} + +func TestHandleSimpleStats(t *testing.T) { + router := &Router{} + req := httptest.NewRequest(http.MethodGet, "/simple-stats", nil) + rec := httptest.NewRecorder() + + router.handleSimpleStats(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) + } + if ct := rec.Header().Get("Content-Type"); ct != "text/html; charset=utf-8" { + t.Fatalf("expected text/html content type, got %q", ct) + } + if !strings.Contains(rec.Body.String(), "Simple Pulse Stats") { + t.Fatalf("expected stats page HTML, got %q", rec.Body.String()) + } +} + +func TestHandleSocketIO_RedirectsForJS(t *testing.T) { + router := &Router{} + req := httptest.NewRequest(http.MethodGet, "/socket.io/socket.io.js", nil) + rec := httptest.NewRecorder() + + router.handleSocketIO(rec, req) + + if rec.Code != http.StatusFound { + t.Fatalf("expected status %d, got %d", http.StatusFound, rec.Code) + } + if location := rec.Header().Get("Location"); location != "https://cdn.socket.io/4.8.1/socket.io.min.js" { + t.Fatalf("unexpected redirect location: %q", location) + } +} + +func TestHandleSocketIO_PollingHandshake(t *testing.T) { + router := &Router{} + req := httptest.NewRequest(http.MethodGet, "/socket.io/?transport=polling", nil) + rec := httptest.NewRecorder() + + router.handleSocketIO(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) + } + if ct := rec.Header().Get("Content-Type"); ct != "text/plain; charset=UTF-8" { + t.Fatalf("expected text/plain content type, got %q", ct) + } + body := rec.Body.String() + if !strings.HasPrefix(body, "0{") || !strings.Contains(body, "\"sid\"") { + t.Fatalf("unexpected polling handshake body: %q", body) + } +} + +func TestHandleSocketIO_PollingConnected(t *testing.T) { + router := &Router{} + req := httptest.NewRequest(http.MethodGet, "/socket.io/?transport=polling&sid=abc", nil) + rec := httptest.NewRecorder() + + router.handleSocketIO(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) + } + if body := rec.Body.String(); body != "6" { + t.Fatalf("unexpected polling body: %q", body) + } +} + +func TestHandleSocketIO_DefaultRedirect(t *testing.T) { + router := &Router{} + req := httptest.NewRequest(http.MethodGet, "/socket.io/?foo=bar", nil) + rec := httptest.NewRecorder() + + router.handleSocketIO(rec, req) + + if rec.Code != http.StatusFound { + t.Fatalf("expected status %d, got %d", http.StatusFound, rec.Code) + } + if location := rec.Header().Get("Location"); location != "/ws" { + t.Fatalf("unexpected redirect location: %q", location) + } +} + +func TestHandleDownloadInstallScript_Fallback(t *testing.T) { + root := t.TempDir() + scriptPath := filepath.Join(root, "scripts", "install-docker-agent.sh") + if err := os.MkdirAll(filepath.Dir(scriptPath), 0o755); err != nil { + t.Fatalf("mkdir scripts dir: %v", err) + } + if err := os.WriteFile(scriptPath, []byte("#!/bin/sh\necho hi\n"), 0o644); err != nil { + t.Fatalf("write script: %v", err) + } + + router := &Router{projectRoot: root} + req := httptest.NewRequest(http.MethodGet, "/download/install-docker-agent.sh", nil) + rec := httptest.NewRecorder() + + router.handleDownloadInstallScript(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) + } + if cache := rec.Header().Get("Cache-Control"); !strings.Contains(cache, "no-cache") { + t.Fatalf("expected no-cache header, got %q", cache) + } +} + +func TestHandleDownloadHostAgentInstallScript_Fallback(t *testing.T) { + root := t.TempDir() + scriptPath := filepath.Join(root, "scripts", "install.sh") + if err := os.MkdirAll(filepath.Dir(scriptPath), 0o755); err != nil { + t.Fatalf("mkdir scripts dir: %v", err) + } + if err := os.WriteFile(scriptPath, []byte("#!/bin/sh\necho host\n"), 0o644); err != nil { + t.Fatalf("write script: %v", err) + } + + router := &Router{projectRoot: root} + req := httptest.NewRequest(http.MethodGet, "/download/install.sh", nil) + rec := httptest.NewRecorder() + + router.handleDownloadHostAgentInstallScript(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) + } + if cache := rec.Header().Get("Cache-Control"); !strings.Contains(cache, "no-cache") { + t.Fatalf("expected no-cache header, got %q", cache) + } +} + +func TestHandleDownloadAgent_Found(t *testing.T) { + binDir := t.TempDir() + t.Setenv("PULSE_BIN_DIR", binDir) + payload := []byte("docker-agent-binary") + filePath := filepath.Join(binDir, "pulse-docker-agent-linux-arm64") + if err := os.WriteFile(filePath, payload, 0o755); err != nil { + t.Fatalf("write binary: %v", err) + } + + router := &Router{} + req := httptest.NewRequest(http.MethodGet, "/download/pulse-docker-agent?arch=arm64", nil) + rec := httptest.NewRecorder() + + router.handleDownloadAgent(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) + } + expected := fmt.Sprintf("%x", sha256.Sum256(payload)) + if checksum := rec.Header().Get("X-Checksum-Sha256"); checksum != expected { + t.Fatalf("unexpected checksum header: %q", checksum) + } + if rec.Body.String() != string(payload) { + t.Fatalf("unexpected response body: %q", rec.Body.String()) + } +} + +func TestHandleDownloadAgent_NotFound(t *testing.T) { + binDir := t.TempDir() + t.Setenv("PULSE_BIN_DIR", binDir) + + router := &Router{} + req := httptest.NewRequest(http.MethodGet, "/download/pulse-docker-agent", nil) + rec := httptest.NewRecorder() + + router.handleDownloadAgent(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("expected status %d, got %d", http.StatusNotFound, rec.Code) + } +} + +func TestDownloadScript_MethodNotAllowed(t *testing.T) { + router := &Router{} + cases := []struct { + name string + handler func(http.ResponseWriter, *http.Request) + }{ + {name: "container-install", handler: router.handleDownloadContainerAgentInstallScript}, + {name: "host-install-ps", handler: router.handleDownloadHostAgentInstallScriptPS}, + {name: "host-uninstall", handler: router.handleDownloadHostAgentUninstallScript}, + {name: "host-uninstall-ps", handler: router.handleDownloadHostAgentUninstallScriptPS}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/download", nil) + rec := httptest.NewRecorder() + + tc.handler(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code) + } + }) + } +} diff --git a/internal/api/router_helpers_more_test.go b/internal/api/router_helpers_more_test.go new file mode 100644 index 000000000..8b5b0eb1d --- /dev/null +++ b/internal/api/router_helpers_more_test.go @@ -0,0 +1,143 @@ +package api + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/models" + "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" +) + +func TestRouterGetMonitor_Defaults(t *testing.T) { + defaultMonitor, _, _ := newTestMonitor(t) + router := &Router{monitor: defaultMonitor} + + req := httptest.NewRequest(http.MethodGet, "/", nil) + monitor, err := router.getMonitor(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if monitor != defaultMonitor { + t.Fatalf("expected default monitor to be returned") + } +} + +func TestRouterGetMonitor_WithTenant(t *testing.T) { + defaultMonitor, _, _ := newTestMonitor(t) + tenantMonitor, _, _ := newTestMonitor(t) + + mtm := &monitoring.MultiTenantMonitor{} + setUnexportedField(t, mtm, "monitors", map[string]*monitoring.Monitor{ + "tenant-1": tenantMonitor, + }) + + router := &Router{monitor: defaultMonitor, mtMonitor: mtm} + + req := httptest.NewRequest(http.MethodGet, "/", nil) + ctx := context.WithValue(req.Context(), OrgIDContextKey, "tenant-1") + req = req.WithContext(ctx) + + monitor, err := router.getMonitor(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if monitor != tenantMonitor { + t.Fatalf("expected tenant monitor to be returned") + } +} + +func TestMultiTenantStateProvider_DefaultAndTenant(t *testing.T) { + defaultMonitor, defaultState, _ := newTestMonitor(t) + defaultState.VMs = []models.VM{{ID: "vm-default"}} + + tenantMonitor, tenantState, _ := newTestMonitor(t) + tenantState.VMs = []models.VM{{ID: "vm-tenant"}} + + mtm := &monitoring.MultiTenantMonitor{} + setUnexportedField(t, mtm, "monitors", map[string]*monitoring.Monitor{ + "tenant-1": tenantMonitor, + }) + + provider := NewMultiTenantStateProvider(mtm, defaultMonitor) + + snapDefault := provider.GetStateForTenant("") + if len(snapDefault.VMs) != 1 || snapDefault.VMs[0].ID != "vm-default" { + t.Fatalf("unexpected default snapshot: %#v", snapDefault.VMs) + } + snapDefault = provider.GetStateForTenant("default") + if len(snapDefault.VMs) != 1 || snapDefault.VMs[0].ID != "vm-default" { + t.Fatalf("unexpected default snapshot: %#v", snapDefault.VMs) + } + + snapTenant := provider.GetStateForTenant("tenant-1") + if len(snapTenant.VMs) != 1 || snapTenant.VMs[0].ID != "vm-tenant" { + t.Fatalf("unexpected tenant snapshot: %#v", snapTenant.VMs) + } +} + +func TestMultiTenantStateProvider_FallbackOnError(t *testing.T) { + defaultMonitor, defaultState, _ := newTestMonitor(t) + defaultState.VMs = []models.VM{{ID: "vm-default"}} + + mtp := config.NewMultiTenantPersistence(t.TempDir()) + mtm := monitoring.NewMultiTenantMonitor(&config.Config{}, mtp, nil) + defer mtm.Stop() + + provider := NewMultiTenantStateProvider(mtm, defaultMonitor) + + snap := provider.GetStateForTenant("../bad") + if len(snap.VMs) != 1 || snap.VMs[0].ID != "vm-default" { + t.Fatalf("expected fallback snapshot, got %#v", snap.VMs) + } +} + +func TestSetMultiTenantMonitor_WiresHandlers(t *testing.T) { + defaultMonitor, _, _ := newTestMonitor(t) + mtm := &monitoring.MultiTenantMonitor{} + setUnexportedField(t, mtm, "monitors", map[string]*monitoring.Monitor{ + "default": defaultMonitor, + }) + + router := &Router{ + alertHandlers: &AlertHandlers{}, + notificationHandlers: &NotificationHandlers{}, + dockerAgentHandlers: &DockerAgentHandlers{}, + hostAgentHandlers: &HostAgentHandlers{}, + kubernetesAgentHandlers: &KubernetesAgentHandlers{}, + systemSettingsHandler: &SystemSettingsHandler{}, + resourceHandlers: &ResourceHandlers{}, + } + + router.SetMultiTenantMonitor(mtm) + + if router.mtMonitor != mtm { + t.Fatalf("expected router mtMonitor to be updated") + } + if router.monitor != defaultMonitor { + t.Fatalf("expected router monitor to be set to default monitor") + } + if router.alertHandlers.mtMonitor != mtm { + t.Fatalf("expected alertHandlers mtMonitor to be set") + } + if router.notificationHandlers.mtMonitor != mtm { + t.Fatalf("expected notificationHandlers mtMonitor to be set") + } + if router.dockerAgentHandlers.mtMonitor != mtm { + t.Fatalf("expected dockerAgentHandlers mtMonitor to be set") + } + if router.hostAgentHandlers.mtMonitor != mtm { + t.Fatalf("expected hostAgentHandlers mtMonitor to be set") + } + if router.kubernetesAgentHandlers.mtMonitor != mtm { + t.Fatalf("expected kubernetesAgentHandlers mtMonitor to be set") + } + if router.systemSettingsHandler.mtMonitor != mtm { + t.Fatalf("expected systemSettingsHandler mtMonitor to be set") + } + if router.resourceHandlers.tenantStateProvider == nil { + t.Fatalf("expected tenant state provider to be set") + } +} diff --git a/internal/api/router_low_coverage_additional_test.go b/internal/api/router_low_coverage_additional_test.go new file mode 100644 index 000000000..646c68fd8 --- /dev/null +++ b/internal/api/router_low_coverage_additional_test.go @@ -0,0 +1,247 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/rcourtman/pulse-go-rewrite/internal/ai/knowledge" + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/models" + "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" + "github.com/rcourtman/pulse-go-rewrite/pkg/metrics" +) + +func TestHandleMetricsStoreStats_MethodNotAllowed(t *testing.T) { + router := &Router{} + req := httptest.NewRequest(http.MethodPost, "/api/metrics/store/stats", nil) + rec := httptest.NewRecorder() + + router.handleMetricsStoreStats(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusMethodNotAllowed) + } +} + +func TestHandleMetricsStoreStats_NoMonitor(t *testing.T) { + router := &Router{} + req := httptest.NewRequest(http.MethodGet, "/api/metrics/store/stats", nil) + rec := httptest.NewRecorder() + + router.handleMetricsStoreStats(rec, req) + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusInternalServerError) + } +} + +func TestHandleMetricsStoreStats_NoStore(t *testing.T) { + monitor, _, _ := newTestMonitor(t) + router := &Router{monitor: monitor} + + req := httptest.NewRequest(http.MethodGet, "/api/metrics/store/stats", nil) + rec := httptest.NewRecorder() + + router.handleMetricsStoreStats(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + var payload map[string]interface{} + if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if payload["enabled"] != false { + t.Fatalf("expected enabled=false, got %#v", payload["enabled"]) + } +} + +func TestHandleMetricsStoreStats_WithStore(t *testing.T) { + monitor, _, _ := newTestMonitor(t) + store, err := metrics.NewStore(metrics.DefaultConfig(t.TempDir())) + if err != nil { + t.Fatalf("metrics.NewStore error: %v", err) + } + defer store.Close() + + setUnexportedField(t, monitor, "metricsStore", store) + router := &Router{monitor: monitor} + + req := httptest.NewRequest(http.MethodGet, "/api/metrics/store/stats", nil) + rec := httptest.NewRecorder() + + router.handleMetricsStoreStats(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + var payload map[string]interface{} + if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if payload["enabled"] != true { + t.Fatalf("expected enabled=true, got %#v", payload["enabled"]) + } +} + +func TestHandleDiagnosticsDockerPrepareToken_MethodNotAllowed(t *testing.T) { + router := &Router{} + req := httptest.NewRequest(http.MethodGet, "/api/diagnostics/docker/prepare-token", nil) + rec := httptest.NewRecorder() + + router.handleDiagnosticsDockerPrepareToken(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusMethodNotAllowed) + } +} + +func TestHandleDiagnosticsDockerPrepareToken_InvalidJSON(t *testing.T) { + router := &Router{monitor: &monitoring.Monitor{}, config: &config.Config{}} + req := httptest.NewRequest(http.MethodPost, "/api/diagnostics/docker/prepare-token", strings.NewReader("{")) + rec := httptest.NewRecorder() + + router.handleDiagnosticsDockerPrepareToken(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest) + } +} + +func TestHandleDiagnosticsDockerPrepareToken_MissingHostID(t *testing.T) { + router := &Router{monitor: &monitoring.Monitor{}, config: &config.Config{}} + body := bytes.NewBufferString(`{"hostId":""}`) + req := httptest.NewRequest(http.MethodPost, "/api/diagnostics/docker/prepare-token", body) + rec := httptest.NewRecorder() + + router.handleDiagnosticsDockerPrepareToken(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest) + } +} + +func TestHandleDiagnosticsDockerPrepareToken_HostNotFound(t *testing.T) { + monitor, _, _ := newTestMonitor(t) + router := &Router{monitor: monitor, config: &config.Config{}} + body := bytes.NewBufferString(`{"hostId":"missing"}`) + req := httptest.NewRequest(http.MethodPost, "/api/diagnostics/docker/prepare-token", body) + rec := httptest.NewRecorder() + + router.handleDiagnosticsDockerPrepareToken(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound) + } +} + +func TestHandleDiagnosticsDockerPrepareToken_Success(t *testing.T) { + monitor, state, _ := newTestMonitor(t) + state.DockerHosts = []models.DockerHost{{ID: "host-1", DisplayName: "Docker Host"}} + + router := &Router{monitor: monitor, config: &config.Config{PublicURL: "https://pulse.example.com"}} + body := bytes.NewBufferString(`{"hostId":"host-1","tokenName":""}`) + req := httptest.NewRequest(http.MethodPost, "/api/diagnostics/docker/prepare-token", body) + rec := httptest.NewRecorder() + + router.handleDiagnosticsDockerPrepareToken(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + var payload map[string]interface{} + if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if ok, _ := payload["success"].(bool); !ok { + t.Fatalf("expected success true, got %#v", payload["success"]) + } + if payload["token"] == "" { + t.Fatalf("expected token in response") + } + host, _ := payload["host"].(map[string]interface{}) + if host["id"] != "host-1" { + t.Fatalf("unexpected host id: %#v", host["id"]) + } + if !strings.Contains(payload["installCommand"].(string), "https://pulse.example.com") { + t.Fatalf("expected install command to include base URL") + } + if len(router.config.APITokens) == 0 { + t.Fatalf("expected API token to be recorded") + } +} + +func TestHandleDownloadDockerInstallerScript_MethodNotAllowed(t *testing.T) { + router := &Router{} + req := httptest.NewRequest(http.MethodPost, "/download/install-docker.sh", nil) + rec := httptest.NewRecorder() + + router.handleDownloadDockerInstallerScript(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusMethodNotAllowed) + } +} + +func TestHandleDownloadDockerInstallerScript_ServesFile(t *testing.T) { + root := t.TempDir() + scriptPath := filepath.Join(root, "scripts", "install-docker.sh") + if err := os.MkdirAll(filepath.Dir(scriptPath), 0o755); err != nil { + t.Fatalf("mkdir scripts dir: %v", err) + } + if err := os.WriteFile(scriptPath, []byte("#!/bin/sh\necho docker\n"), 0o644); err != nil { + t.Fatalf("write script: %v", err) + } + + router := &Router{projectRoot: root} + req := httptest.NewRequest(http.MethodGet, "/download/install-docker.sh", nil) + rec := httptest.NewRecorder() + + router.handleDownloadDockerInstallerScript(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + if ct := rec.Header().Get("Content-Type"); ct != "text/x-shellscript" { + t.Fatalf("expected text/x-shellscript, got %q", ct) + } +} + +func TestKnowledgeStoreProviderWrapper(t *testing.T) { + wrapper := &knowledgeStoreProviderWrapper{} + if err := wrapper.SaveNote("res-1", "note", "service"); err == nil { + t.Fatalf("expected error when store is nil") + } + if got := wrapper.GetKnowledge("res-1", ""); got != nil { + t.Fatalf("expected nil knowledge when store is nil") + } + + store, err := knowledge.NewStore(t.TempDir()) + if err != nil { + t.Fatalf("knowledge.NewStore error: %v", err) + } + wrapper.store = store + + if err := wrapper.SaveNote("res-1", "hello", "service"); err != nil { + t.Fatalf("SaveNote error: %v", err) + } + + entries := wrapper.GetKnowledge("res-1", "service") + if len(entries) != 1 { + t.Fatalf("expected 1 entry, got %d", len(entries)) + } + if entries[0].Category != "service" || entries[0].Note != "hello" { + t.Fatalf("unexpected entry: %#v", entries[0]) + } + + all := wrapper.GetKnowledge("res-1", "") + if len(all) != 1 { + t.Fatalf("expected 1 entry from full query, got %d", len(all)) + } +} diff --git a/internal/api/router_version_tenant_metrics_test.go b/internal/api/router_version_tenant_metrics_test.go new file mode 100644 index 000000000..f79f60de5 --- /dev/null +++ b/internal/api/router_version_tenant_metrics_test.go @@ -0,0 +1,234 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/config" + "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" + "github.com/rcourtman/pulse-go-rewrite/internal/updates" + "github.com/rcourtman/pulse-go-rewrite/pkg/metrics" +) + +func TestGetTenantMonitor_Default(t *testing.T) { + defaultMonitor, _, _ := newTestMonitor(t) + router := &Router{monitor: defaultMonitor} + + if got := router.getTenantMonitor(context.Background()); got != defaultMonitor { + t.Fatalf("expected default monitor to be returned") + } +} + +func TestGetTenantMonitor_WithTenant(t *testing.T) { + defaultMonitor, _, _ := newTestMonitor(t) + tenantMonitor, _, _ := newTestMonitor(t) + + mtm := &monitoring.MultiTenantMonitor{} + setUnexportedField(t, mtm, "monitors", map[string]*monitoring.Monitor{ + "tenant-1": tenantMonitor, + }) + + router := &Router{monitor: defaultMonitor, mtMonitor: mtm} + ctx := context.WithValue(context.Background(), OrgIDContextKey, "tenant-1") + + if got := router.getTenantMonitor(ctx); got != tenantMonitor { + t.Fatalf("expected tenant monitor to be returned") + } +} + +func TestGetTenantMonitor_FallbackOnError(t *testing.T) { + defaultMonitor, _, _ := newTestMonitor(t) + mtp := config.NewMultiTenantPersistence(t.TempDir()) + mtm := monitoring.NewMultiTenantMonitor(&config.Config{}, mtp, nil) + defer mtm.Stop() + + router := &Router{monitor: defaultMonitor, mtMonitor: mtm} + ctx := context.WithValue(context.Background(), OrgIDContextKey, "../bad") + + if got := router.getTenantMonitor(ctx); got != defaultMonitor { + t.Fatalf("expected fallback to default monitor") + } +} + +func TestHandleVersion_MethodNotAllowed(t *testing.T) { + router := &Router{updateManager: updates.NewManager(&config.Config{})} + req := httptest.NewRequest(http.MethodPost, "/api/version", nil) + rec := httptest.NewRecorder() + + router.handleVersion(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusMethodNotAllowed) + } +} + +func TestHandleVersion_Success(t *testing.T) { + router := &Router{updateManager: updates.NewManager(&config.Config{})} + req := httptest.NewRequest(http.MethodGet, "/api/version", nil) + rec := httptest.NewRecorder() + + router.handleVersion(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + var payload map[string]interface{} + if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if payload["version"] == "" { + t.Fatalf("expected version in response, got %#v", payload) + } +} + +func TestHandleMetricsHistory_MethodNotAllowed(t *testing.T) { + router := &Router{} + req := httptest.NewRequest(http.MethodPost, "/api/metrics/history", nil) + rec := httptest.NewRecorder() + + router.handleMetricsHistory(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusMethodNotAllowed) + } +} + +func TestHandleMetricsHistory_MissingParams(t *testing.T) { + monitor, _, _ := newTestMonitor(t) + router := &Router{monitor: monitor} + req := httptest.NewRequest(http.MethodGet, "/api/metrics/history", nil) + rec := httptest.NewRecorder() + + router.handleMetricsHistory(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest) + } +} + +func TestHandleMetricsHistory_LicenseRequired(t *testing.T) { + monitor, _, _ := newTestMonitor(t) + mtp := config.NewMultiTenantPersistence(t.TempDir()) + if _, err := mtp.GetPersistence("default"); err != nil { + t.Fatalf("failed to init persistence: %v", err) + } + + router := &Router{ + monitor: monitor, + licenseHandlers: NewLicenseHandlers(mtp), + } + + req := httptest.NewRequest(http.MethodGet, "/api/metrics-store/history?resourceType=vm&resourceId=vm-1&range=30d", nil) + rec := httptest.NewRecorder() + + router.handleMetricsHistory(rec, req) + + if rec.Code != http.StatusPaymentRequired { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusPaymentRequired) + } +} + +func TestHandleMetricsHistory_UsesStore(t *testing.T) { + monitor, _, _ := newTestMonitor(t) + store, err := metrics.NewStore(metrics.DefaultConfig(t.TempDir())) + if err != nil { + t.Fatalf("metrics.NewStore error: %v", err) + } + defer store.Close() + + store.WriteBatchSync([]metrics.WriteMetric{{ + ResourceType: "vm", + ResourceID: "vm-1", + MetricType: "cpu", + Value: 42.0, + Timestamp: time.Now(), + Tier: metrics.TierRaw, + }}) + + setUnexportedField(t, monitor, "metricsStore", store) + router := &Router{monitor: monitor} + + req := httptest.NewRequest(http.MethodGet, "/api/metrics-store/history?resourceType=vm&resourceId=vm-1&metric=cpu&range=1h", nil) + rec := httptest.NewRecorder() + + router.handleMetricsHistory(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + var payload map[string]interface{} + if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if payload["source"] != "store" { + t.Fatalf("expected source store, got %#v", payload["source"]) + } +} + +func TestHandleMetricsHistory_UsesStoreAllMetrics(t *testing.T) { + monitor, _, _ := newTestMonitor(t) + store, err := metrics.NewStore(metrics.DefaultConfig(t.TempDir())) + if err != nil { + t.Fatalf("metrics.NewStore error: %v", err) + } + defer store.Close() + + now := time.Now() + store.WriteBatchSync([]metrics.WriteMetric{ + { + ResourceType: "vm", + ResourceID: "vm-1", + MetricType: "cpu", + Value: 50.0, + Timestamp: now, + Tier: metrics.TierRaw, + }, + { + ResourceType: "vm", + ResourceID: "vm-1", + MetricType: "memory", + Value: 70.0, + Timestamp: now, + Tier: metrics.TierRaw, + }, + }) + + setUnexportedField(t, monitor, "metricsStore", store) + router := &Router{monitor: monitor} + + req := httptest.NewRequest(http.MethodGet, "/api/metrics-store/history?resourceType=vm&resourceId=vm-1&range=1h", nil) + rec := httptest.NewRecorder() + + router.handleMetricsHistory(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + var payload map[string]interface{} + if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if payload["source"] != "store" { + t.Fatalf("expected source store, got %#v", payload["source"]) + } + metricsMap, ok := payload["metrics"].(map[string]interface{}) + if !ok || metricsMap["cpu"] == nil { + t.Fatalf("expected cpu metrics in response, got %#v", payload["metrics"]) + } +} + +func TestHandleCharts_MethodNotAllowed(t *testing.T) { + router := &Router{} + req := httptest.NewRequest(http.MethodPost, "/api/charts", nil) + rec := httptest.NewRecorder() + + router.handleCharts(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusMethodNotAllowed) + } +}