Pulse/internal/api/discovery_handlers_test.go
2026-03-18 16:06:30 +00:00

540 lines
18 KiB
Go

package api
import (
"bytes"
"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/servicediscovery"
internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
// MockCommandExecutor for deep scanner
type MockCommandExecutor struct {
mock.Mock
}
func (m *MockCommandExecutor) ExecuteCommand(ctx context.Context, agentID string, cmd servicediscovery.ExecuteCommandPayload) (*servicediscovery.CommandResultPayload, error) {
args := m.Called(ctx, agentID, cmd)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*servicediscovery.CommandResultPayload), args.Error(1)
}
func (m *MockCommandExecutor) GetConnectedAgents() []servicediscovery.ConnectedAgent {
args := m.Called()
return args.Get(0).([]servicediscovery.ConnectedAgent)
}
func (m *MockCommandExecutor) IsAgentConnected(agentID string) bool {
args := m.Called(agentID)
return args.Bool(0)
}
func setupDiscoveryHandlers(t *testing.T) (*DiscoveryHandlers, *servicediscovery.Service, *servicediscovery.Store) {
// Create temp dir
tmpDir := t.TempDir()
// Create real store
store, err := servicediscovery.NewStore(tmpDir)
require.NoError(t, err)
// Create real deep scanner with mock executor
mockExecutor := new(MockCommandExecutor)
scanner := servicediscovery.NewDeepScanner(mockExecutor)
// Create service
cfg := servicediscovery.DefaultConfig()
service := servicediscovery.NewService(store, scanner, cfg)
// Create config for handlers (needed for admin check)
hashed, err := internalauth.HashPassword("admin")
require.NoError(t, err)
apiCfg := &config.Config{
AuthUser: "admin",
AuthPass: hashed,
}
// Create handlers
handlers := NewDiscoveryHandlers(service, apiCfg)
return handlers, service, store
}
func TestHandleListDiscoveries(t *testing.T) {
h, _, store := setupDiscoveryHandlers(t)
// Seed some data
discovery := &servicediscovery.ResourceDiscovery{
ID: "test:1",
ResourceType: servicediscovery.ResourceTypeVM,
ResourceID: "100",
TargetID: "node1",
ServiceName: "Test Service",
}
require.NoError(t, store.Save(discovery))
req := httptest.NewRequest("GET", "/api/discovery", nil)
w := httptest.NewRecorder()
h.HandleListDiscoveries(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var result map[string]interface{}
err := json.NewDecoder(w.Body).Decode(&result)
require.NoError(t, err)
assert.Equal(t, float64(1), result["total"])
discoveries := result["discoveries"].([]interface{})
assert.Len(t, discoveries, 1)
first := discoveries[0].(map[string]interface{})
assert.Equal(t, "Test Service", first["service_name"])
assert.NotContains(t, first, "host_id")
}
func TestHandleGetDiscovery(t *testing.T) {
h, _, store := setupDiscoveryHandlers(t)
discovery := &servicediscovery.ResourceDiscovery{
ID: "vm:node1:100",
ResourceType: servicediscovery.ResourceTypeVM,
ResourceID: "100",
TargetID: "node1",
ServiceName: "Test Service",
UserSecrets: map[string]string{"key": "secret"},
}
require.NoError(t, store.Save(discovery))
// Test Admin Request (sees secrets)
// We cheat by passing Basic Auth which isAdminRequest checks
req := httptest.NewRequest("GET", "/api/discovery/vm/node1/100", nil)
req.SetBasicAuth("admin", "admin")
w := httptest.NewRecorder()
h.HandleGetDiscovery(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var result servicediscovery.ResourceDiscovery
require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
assert.Equal(t, "Test Service", result.ServiceName)
assert.Equal(t, "secret", result.UserSecrets["key"])
// Test Non-Admin Request (redacted secrets)
req = httptest.NewRequest("GET", "/api/discovery/vm/node1/100", nil)
w = httptest.NewRecorder()
h.HandleGetDiscovery(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var resultRedacted servicediscovery.ResourceDiscovery
require.NoError(t, json.NewDecoder(w.Body).Decode(&resultRedacted))
assert.Nil(t, resultRedacted.UserSecrets)
}
func TestHandleGetDiscovery_SessionAdminRequiresConfiguredAdminUser(t *testing.T) {
h, _, store := setupDiscoveryHandlers(t)
h.config.AuthUser = "admin"
discovery := &servicediscovery.ResourceDiscovery{
ID: "vm:node1:101",
ResourceType: servicediscovery.ResourceTypeVM,
ResourceID: "101",
TargetID: "node1",
ServiceName: "Session Test Service",
UserSecrets: map[string]string{"key": "secret"},
}
require.NoError(t, store.Save(discovery))
memberSession := "discovery-member-session"
GetSessionStore().CreateSession(memberSession, time.Hour, "agent", "127.0.0.1", "member")
req := httptest.NewRequest("GET", "/api/discovery/vm/node1/101", nil)
req.AddCookie(&http.Cookie{Name: "pulse_session", Value: memberSession})
w := httptest.NewRecorder()
h.HandleGetDiscovery(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var redacted servicediscovery.ResourceDiscovery
require.NoError(t, json.NewDecoder(w.Body).Decode(&redacted))
assert.Nil(t, redacted.UserSecrets)
adminSession := "discovery-admin-session"
GetSessionStore().CreateSession(adminSession, time.Hour, "agent", "127.0.0.1", "admin")
req = httptest.NewRequest("GET", "/api/discovery/vm/node1/101", nil)
req.AddCookie(&http.Cookie{Name: "pulse_session", Value: adminSession})
w = httptest.NewRecorder()
h.HandleGetDiscovery(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var full servicediscovery.ResourceDiscovery
require.NoError(t, json.NewDecoder(w.Body).Decode(&full))
assert.Equal(t, "secret", full.UserSecrets["key"])
}
func TestHandleGetDiscovery_TokenAdminRequiresSettingsWriteScope(t *testing.T) {
h, _, store := setupDiscoveryHandlers(t)
readToken, err := config.NewAPITokenRecord("discovery-read-token-123.12345678", "read", []string{config.ScopeMonitoringRead})
require.NoError(t, err)
writeToken, err := config.NewAPITokenRecord("discovery-write-token-123.12345678", "write", []string{config.ScopeSettingsWrite})
require.NoError(t, err)
h.config.APITokens = []config.APITokenRecord{*readToken, *writeToken}
h.config.SortAPITokens()
discovery := &servicediscovery.ResourceDiscovery{
ID: "vm:node1:102",
ResourceType: servicediscovery.ResourceTypeVM,
ResourceID: "102",
TargetID: "node1",
ServiceName: "Token Test Service",
UserSecrets: map[string]string{"key": "secret"},
}
require.NoError(t, store.Save(discovery))
req := httptest.NewRequest("GET", "/api/discovery/vm/node1/102", nil)
req.Header.Set("X-API-Token", "discovery-read-token-123.12345678")
w := httptest.NewRecorder()
h.HandleGetDiscovery(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var redacted servicediscovery.ResourceDiscovery
require.NoError(t, json.NewDecoder(w.Body).Decode(&redacted))
assert.Nil(t, redacted.UserSecrets)
req = httptest.NewRequest("GET", "/api/discovery/vm/node1/102", nil)
req.Header.Set("X-API-Token", "discovery-write-token-123.12345678")
w = httptest.NewRecorder()
h.HandleGetDiscovery(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var full servicediscovery.ResourceDiscovery
require.NoError(t, json.NewDecoder(w.Body).Decode(&full))
assert.Equal(t, "secret", full.UserSecrets["key"])
}
func TestHandleGetDiscovery_ForgedBasicHeaderDoesNotBypassAdmin(t *testing.T) {
h, _, store := setupDiscoveryHandlers(t)
discovery := &servicediscovery.ResourceDiscovery{
ID: "vm:node1:103",
ResourceType: servicediscovery.ResourceTypeVM,
ResourceID: "103",
TargetID: "node1",
ServiceName: "Forged Basic Test",
UserSecrets: map[string]string{"key": "secret"},
}
require.NoError(t, store.Save(discovery))
req := httptest.NewRequest("GET", "/api/discovery/vm/node1/103", nil)
req.SetBasicAuth("admin", "wrong-password")
w := httptest.NewRecorder()
h.HandleGetDiscovery(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var redacted servicediscovery.ResourceDiscovery
require.NoError(t, json.NewDecoder(w.Body).Decode(&redacted))
assert.Nil(t, redacted.UserSecrets)
}
func TestHandleGetDiscovery_NotFound(t *testing.T) {
h, _, _ := setupDiscoveryHandlers(t)
req := httptest.NewRequest("GET", "/api/discovery/vm/node1/999", nil)
w := httptest.NewRecorder()
h.HandleGetDiscovery(w, req)
assert.Equal(t, http.StatusNotFound, w.Code)
}
func TestHandleTriggerDiscovery(t *testing.T) {
h, _, _ := setupDiscoveryHandlers(t)
reqBody := `{"force": true, "hostname": "my-vm"}`
req := httptest.NewRequest("POST", "/api/discovery/vm/node1/100", bytes.NewBufferString(reqBody))
w := httptest.NewRecorder()
// This will fail because MockCommandExecutor returns error for unmocked calls
// OR because the service tries to actually run discovery logic which might depend on other things.
// However, HandleTriggerDiscovery calls svc.DiscoverResource -> which calls scanner.Scan
// Let's see if we can get it to run without crashing.
h.HandleTriggerDiscovery(w, req)
// Since we mock nothing on executor and don't set an AI analyzer,
// the discovery might fail or return basic info.
// Actually, DiscoverResource calls deep scanner immediately if forced.
// DeepScanner needs executor. Since we didn't mock "Execute", it will panic or return specific mock error?
// Wait, MockCommandExecutor will panic if unexpected call.
// So we expect 500 or panic unless we configure mock.
// Let's assume for this basic test we just want to ensure routing works.
// A 500 is "success" in terms of reaching the handler logic vs 404.
assert.True(t, w.Code == http.StatusInternalServerError || w.Code == http.StatusOK)
}
func TestHandleUpdateNotes(t *testing.T) {
h, svc, store := setupDiscoveryHandlers(t)
id := "vm:node1:100"
discovery := &servicediscovery.ResourceDiscovery{
ID: id,
ResourceType: servicediscovery.ResourceTypeVM,
ResourceID: "100",
TargetID: "node1",
ServiceName: "Old Name",
}
require.NoError(t, store.Save(discovery))
reqBody := `{"user_notes": "Updated notes", "user_secrets": {"token": "123"}}`
// Non-admin cannot set secrets
req := httptest.NewRequest("PUT", "/api/discovery/vm/node1/100/notes", bytes.NewBufferString(reqBody))
w := httptest.NewRecorder()
h.HandleUpdateNotes(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
// Admin can
req = httptest.NewRequest("PUT", "/api/discovery/vm/node1/100/notes", bytes.NewBufferString(reqBody))
req.SetBasicAuth("admin", "admin")
w = httptest.NewRecorder()
h.HandleUpdateNotes(w, req)
assert.Equal(t, http.StatusOK, w.Code)
updated, _ := svc.GetDiscovery(id)
assert.Equal(t, "Updated notes", updated.UserNotes)
assert.Equal(t, "123", updated.UserSecrets["token"])
}
func TestHandleDeleteDiscovery(t *testing.T) {
h, svc, store := setupDiscoveryHandlers(t)
id := "vm:node1:100"
discovery := &servicediscovery.ResourceDiscovery{ID: id, ResourceType: servicediscovery.ResourceTypeVM, ResourceID: "100", TargetID: "node1"}
require.NoError(t, store.Save(discovery))
req := httptest.NewRequest("DELETE", "/api/discovery/vm/node1/100", nil)
w := httptest.NewRecorder()
h.HandleDeleteDiscovery(w, req)
assert.Equal(t, http.StatusOK, w.Code)
d, err := svc.GetDiscovery(id)
assert.NoError(t, err)
assert.Nil(t, d)
}
func TestHandleGetStatus(t *testing.T) {
h, _, _ := setupDiscoveryHandlers(t)
req := httptest.NewRequest("GET", "/api/discovery/status", nil)
w := httptest.NewRecorder()
h.HandleGetStatus(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var status map[string]interface{}
require.NoError(t, json.NewDecoder(w.Body).Decode(&status))
assert.Contains(t, status, "running")
}
func TestHandleUpdateSettings(t *testing.T) {
h, _, _ := setupDiscoveryHandlers(t)
// Non-admin
reqBody := `{"max_discovery_age_days": 10}`
req := httptest.NewRequest("PUT", "/api/discovery/settings", bytes.NewBufferString(reqBody))
w := httptest.NewRecorder()
h.HandleUpdateSettings(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
// Admin
req = httptest.NewRequest("PUT", "/api/discovery/settings", bytes.NewBufferString(reqBody))
req.SetBasicAuth("admin", "admin")
w = httptest.NewRecorder()
h.HandleUpdateSettings(w, req)
assert.Equal(t, http.StatusOK, w.Code)
// Verify change (indirectly via status or checking service if field was public)
// We can't check service private field easily, but we check 200 OK.
}
func TestHandleListByType(t *testing.T) {
h, _, store := setupDiscoveryHandlers(t)
d1 := &servicediscovery.ResourceDiscovery{ID: "vm:1", ResourceType: servicediscovery.ResourceTypeVM, ResourceID: "1", TargetID: "h"}
d2 := &servicediscovery.ResourceDiscovery{ID: "lxc:2", ResourceType: servicediscovery.ResourceTypeSystemContainer, ResourceID: "2", TargetID: "h"}
require.NoError(t, store.Save(d1))
require.NoError(t, store.Save(d2))
req := httptest.NewRequest("GET", "/api/discovery/type/vm", nil)
w := httptest.NewRecorder()
h.HandleListByType(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var result map[string]interface{}
require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
discoveries := result["discoveries"].([]interface{})
assert.Len(t, discoveries, 1) // Only VM
}
func TestHandleListByType_RejectsLegacyHostType(t *testing.T) {
h, _, _ := setupDiscoveryHandlers(t)
req := httptest.NewRequest("GET", "/api/discovery/type/host", nil)
w := httptest.NewRecorder()
h.HandleListByType(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
var body map[string]any
require.NoError(t, json.NewDecoder(w.Body).Decode(&body))
assert.Equal(t, `unsupported resource type "host"`, body["message"])
}
func TestParseDiscoveryResourceType_RejectsLegacyHostAlias(t *testing.T) {
tests := []struct {
name string
in string
want servicediscovery.ResourceType
wantErr string
}{
{name: "agent", in: "agent", want: servicediscovery.ResourceTypeAgent},
{name: "vm", in: "vm", want: servicediscovery.ResourceTypeVM},
{name: "mixed case system container", in: " System-Container ", want: servicediscovery.ResourceTypeSystemContainer},
{name: "legacy host rejected", in: "host", wantErr: `unsupported resource type "host"`},
{name: "mixed case host rejected", in: " HoSt ", wantErr: `unsupported resource type "host"`},
{name: "missing required", in: " ", wantErr: "resource type is required"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseDiscoveryResourceType(tt.in)
if tt.wantErr != "" {
require.Error(t, err)
require.Equal(t, tt.wantErr, err.Error())
return
}
require.NoError(t, err)
require.Equal(t, tt.want, got)
})
}
}
func TestHandleListByAgent(t *testing.T) {
h, _, store := setupDiscoveryHandlers(t)
d1 := &servicediscovery.ResourceDiscovery{ID: "vm:1", ResourceType: servicediscovery.ResourceTypeVM, ResourceID: "1", TargetID: "node1"}
d2 := &servicediscovery.ResourceDiscovery{ID: "vm:2", ResourceType: servicediscovery.ResourceTypeVM, ResourceID: "2", TargetID: "node2"}
require.NoError(t, store.Save(d1))
require.NoError(t, store.Save(d2))
req := httptest.NewRequest("GET", "/api/discovery/agent/node1", nil)
w := httptest.NewRecorder()
h.HandleListByAgent(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var result map[string]interface{}
require.NoError(t, json.NewDecoder(w.Body).Decode(&result))
assert.Equal(t, "node1", result["agentId"])
discoveries := result["discoveries"].([]interface{})
assert.Len(t, discoveries, 1) // Only node1
}
func TestHandleGetProgress(t *testing.T) {
h, _, store := setupDiscoveryHandlers(t)
// Case 1: Not started
req := httptest.NewRequest("GET", "/api/discovery/vm/node1/100/progress", nil)
w := httptest.NewRecorder()
h.HandleGetProgress(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var res1 map[string]interface{}
require.NoError(t, json.NewDecoder(w.Body).Decode(&res1))
assert.Equal(t, "not_started", res1["status"])
// Case 2: Completed (if discovery exists)
require.NoError(t, store.Save(&servicediscovery.ResourceDiscovery{ID: "vm:node1:100", ResourceType: "vm", ResourceID: "100", TargetID: "node1"}))
req = httptest.NewRequest("GET", "/api/discovery/vm/node1/100/progress", nil)
w = httptest.NewRecorder()
h.HandleGetProgress(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var res2 map[string]interface{}
require.NoError(t, json.NewDecoder(w.Body).Decode(&res2))
assert.Equal(t, "completed", res2["status"])
}
func TestHandleGetDiscovery_RejectsLegacyHostType(t *testing.T) {
h, _, _ := setupDiscoveryHandlers(t)
req := httptest.NewRequest("GET", "/api/discovery/host/host-1/host-1", nil)
w := httptest.NewRecorder()
h.HandleGetDiscovery(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
var body map[string]any
require.NoError(t, json.NewDecoder(w.Body).Decode(&body))
assert.Equal(t, `unsupported resource type "host"`, body["message"])
}
func TestHandleGetDiscovery_EmitsCanonicalAgentID(t *testing.T) {
h, _, store := setupDiscoveryHandlers(t)
agentDiscovery := &servicediscovery.ResourceDiscovery{
ID: "agent:agent-1:agent-1",
ResourceType: servicediscovery.ResourceTypeAgent,
ResourceID: "agent-1",
TargetID: "agent-1",
Hostname: "agent-1.local",
}
require.NoError(t, store.Save(agentDiscovery))
req := httptest.NewRequest("GET", "/api/discovery/agent/agent-1/agent-1", nil)
w := httptest.NewRecorder()
h.HandleGetDiscovery(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var body map[string]any
require.NoError(t, json.NewDecoder(w.Body).Decode(&body))
assert.Equal(t, "agent-1", body["target_id"])
assert.Equal(t, "agent-1", body["agent_id"])
assert.NotContains(t, body, "host_id")
}
// Additional test to cover service not configured case
func TestHandlers_NoService(t *testing.T) {
h := NewDiscoveryHandlers(nil, nil)
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/", nil)
h.HandleListDiscoveries(w, req)
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
w = httptest.NewRecorder()
h.HandleGetDiscovery(w, req)
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
w = httptest.NewRecorder()
h.HandleTriggerDiscovery(w, req)
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
// check others...
}