package api import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "strings" "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/websocket" agentsdocker "github.com/rcourtman/pulse-go-rewrite/pkg/agents/docker" ) func newDockerAgentHandlers(t *testing.T, cfg *config.Config) (*DockerAgentHandlers, *monitoring.Monitor) { t.Helper() if cfg == nil { cfg = &config.Config{DataPath: t.TempDir()} } if cfg.DataPath == "" { cfg.DataPath = t.TempDir() } monitor, err := monitoring.New(cfg) if err != nil { t.Fatalf("monitoring.New: %v", err) } t.Cleanup(func() { monitor.Stop() }) hub := websocket.NewHub(nil) handler := NewDockerAgentHandlers(nil, monitor, hub, cfg) return handler, monitor } func seedDockerHost(t *testing.T, monitor *monitoring.Monitor) string { t.Helper() report := agentsdocker.Report{ Agent: agentsdocker.AgentInfo{ ID: "agent-1", Version: "1.0.0", IntervalSeconds: 30, }, Host: agentsdocker.HostInfo{ Hostname: "docker-host", Name: "Docker Host", MachineID: "machine-1", DockerVersion: "26.0.0", TotalCPU: 4, TotalMemoryBytes: 8 << 30, UptimeSeconds: 120, }, Timestamp: time.Now().UTC(), } host, err := monitor.ApplyDockerReport(report, nil) if err != nil { t.Fatalf("ApplyDockerReport: %v", err) } if host.ID == "" { t.Fatalf("expected host ID to be set") } return host.ID } func TestDockerAgentHandlers_HandleReport(t *testing.T) { handler, _ := newDockerAgentHandlers(t, nil) report := agentsdocker.Report{ Agent: agentsdocker.AgentInfo{ ID: "agent-2", Version: "1.0.0", IntervalSeconds: 30, }, Host: agentsdocker.HostInfo{ Hostname: "docker-host-2", Name: "Docker Host 2", MachineID: "machine-2", DockerVersion: "26.0.0", TotalCPU: 4, TotalMemoryBytes: 4 << 30, UptimeSeconds: 60, }, Timestamp: time.Now().UTC(), } body, _ := json.Marshal(report) req := httptest.NewRequest(http.MethodPost, "/api/agents/docker/report", bytes.NewReader(body)) rec := httptest.NewRecorder() handler.HandleReport(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String()) } var resp map[string]interface{} if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("decode response: %v", err) } if resp["success"] != true { t.Fatalf("success = %v, want true", resp["success"]) } if resp["agentId"] == "" { t.Fatalf("expected agentId in response") } } // TestDockerAgentHandlers_HandleReport_BlocksNewMonitoredSystemAtLimit verifies // that a new Docker host counts as a monitored system. func TestDockerAgentHandlers_HandleReport_BlocksNewMonitoredSystemAtLimit(t *testing.T) { setMaxMonitoredSystemsLicenseForTests(t, 1) handler, monitor := newDockerAgentHandlers(t, nil) existingHostID := seedDockerHost(t, monitor) if existingHostID == "" { t.Fatalf("expected seeded host ID") } // New Docker host should be blocked because it is a distinct monitored system. newReport := agentsdocker.Report{ Agent: agentsdocker.AgentInfo{ ID: "agent-2", Version: "1.0.0", IntervalSeconds: 30, }, Host: agentsdocker.HostInfo{ Hostname: "docker-host-2", Name: "Docker Host 2", MachineID: "machine-2", DockerVersion: "26.0.0", TotalCPU: 4, TotalMemoryBytes: 4 << 30, UptimeSeconds: 60, }, Timestamp: time.Now().UTC(), } newBody, _ := json.Marshal(newReport) newReq := httptest.NewRequest(http.MethodPost, "/api/agents/docker/report", bytes.NewReader(newBody)) newRec := httptest.NewRecorder() handler.HandleReport(newRec, newReq) if newRec.Code != http.StatusPaymentRequired { t.Fatalf("status = %d, want 402: %s", newRec.Code, newRec.Body.String()) } } func TestDockerAgentHandlers_HandleCommandAck(t *testing.T) { handler, monitor := newDockerAgentHandlers(t, nil) hostID := seedDockerHost(t, monitor) cmdStatus, err := monitor.QueueDockerHostStop(hostID) if err != nil { t.Fatalf("QueueDockerHostStop: %v", err) } reqBody := map[string]string{ "agentId": hostID, "status": "completed", } body, _ := json.Marshal(reqBody) path := "/api/agents/docker/commands/" + cmdStatus.ID + "/ack" req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body)) rec := httptest.NewRecorder() handler.HandleCommandAck(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String()) } } func TestDockerAgentHandlers_HandleCommandAck_RejectsLegacyHostIDAlias(t *testing.T) { handler, monitor := newDockerAgentHandlers(t, nil) hostID := seedDockerHost(t, monitor) cmdStatus, err := monitor.QueueDockerHostStop(hostID) if err != nil { t.Fatalf("QueueDockerHostStop: %v", err) } reqBody := map[string]string{ "hostId": hostID, "status": "completed", } body, _ := json.Marshal(reqBody) path := "/api/agents/docker/commands/" + cmdStatus.ID + "/ack" req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body)) rec := httptest.NewRecorder() handler.HandleCommandAck(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400: %s", rec.Code, rec.Body.String()) } } func TestDockerAgentHandlers_HandleDockerHostActions(t *testing.T) { handler, monitor := newDockerAgentHandlers(t, nil) hostID := seedDockerHost(t, monitor) req := httptest.NewRequest(http.MethodPost, "/api/agents/docker/runtimes/"+hostID+"/allow-reenroll", nil) rec := httptest.NewRecorder() handler.HandleDockerHostActions(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String()) } } func TestDockerAgentHandlers_HandleDeleteHost(t *testing.T) { handler, monitor := newDockerAgentHandlers(t, nil) hostID := seedDockerHost(t, monitor) req := httptest.NewRequest(http.MethodDelete, "/api/agents/docker/runtimes/"+hostID+"?force=true", nil) rec := httptest.NewRecorder() handler.HandleDeleteHost(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String()) } } func TestDockerAgentHandlers_HandleUnhideHost(t *testing.T) { handler, monitor := newDockerAgentHandlers(t, nil) hostID := seedDockerHost(t, monitor) req := httptest.NewRequest(http.MethodPut, "/api/agents/docker/runtimes/"+hostID+"/unhide", nil) rec := httptest.NewRecorder() handler.HandleUnhideHost(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String()) } } func TestDockerAgentHandlers_HandleMarkPendingUninstall(t *testing.T) { handler, monitor := newDockerAgentHandlers(t, nil) hostID := seedDockerHost(t, monitor) req := httptest.NewRequest(http.MethodPut, "/api/agents/docker/runtimes/"+hostID+"/pending-uninstall", nil) rec := httptest.NewRecorder() handler.HandleMarkPendingUninstall(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String()) } } func TestDockerAgentHandlers_HandleSetCustomDisplayName(t *testing.T) { handler, monitor := newDockerAgentHandlers(t, nil) hostID := seedDockerHost(t, monitor) body := []byte(`{"displayName":"My Docker Host"}`) req := httptest.NewRequest(http.MethodPut, "/api/agents/docker/runtimes/"+hostID+"/display-name", bytes.NewReader(body)) rec := httptest.NewRecorder() handler.HandleSetCustomDisplayName(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String()) } } func TestDockerAgentHandlers_HandleContainerUpdate(t *testing.T) { handler, monitor := newDockerAgentHandlers(t, &config.Config{DataPath: t.TempDir()}) hostID := seedDockerHost(t, monitor) reqBody := map[string]string{ "agentId": hostID, "containerId": "container-1", "containerName": "nginx", } body, _ := json.Marshal(reqBody) req := httptest.NewRequest(http.MethodPost, "/api/agents/docker/containers/container-1/update", bytes.NewReader(body)) rec := httptest.NewRecorder() handler.HandleContainerUpdate(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String()) } } func TestDockerAgentHandlers_HandleContainerUpdate_RejectsLegacyHostIDAlias(t *testing.T) { handler, monitor := newDockerAgentHandlers(t, &config.Config{DataPath: t.TempDir()}) hostID := seedDockerHost(t, monitor) reqBody := map[string]string{ "hostId": hostID, "containerId": "container-legacy", "containerName": "nginx", } body, _ := json.Marshal(reqBody) req := httptest.NewRequest(http.MethodPost, "/api/agents/docker/containers/container-legacy/update", bytes.NewReader(body)) rec := httptest.NewRecorder() handler.HandleContainerUpdate(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("status = %d, want 400: %s", rec.Code, rec.Body.String()) } } func TestDockerAgentHandlers_HandleContainerUpdate_Disabled(t *testing.T) { cfg := &config.Config{DataPath: t.TempDir(), DisableDockerUpdateActions: true} handler, monitor := newDockerAgentHandlers(t, cfg) hostID := seedDockerHost(t, monitor) reqBody := map[string]string{ "agentId": hostID, "containerId": "container-2", "containerName": "redis", } body, _ := json.Marshal(reqBody) req := httptest.NewRequest(http.MethodPost, "/api/agents/docker/containers/container-2/update", bytes.NewReader(body)) rec := httptest.NewRecorder() handler.HandleContainerUpdate(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("status = %d, want 403", rec.Code) } } func TestDockerAgentHandlers_HandleCheckUpdates(t *testing.T) { handler, monitor := newDockerAgentHandlers(t, nil) hostID := seedDockerHost(t, monitor) req := httptest.NewRequest(http.MethodPost, "/api/agents/docker/runtimes/"+hostID+"/check-updates", nil) rec := httptest.NewRecorder() handler.HandleCheckUpdates(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String()) } if !strings.Contains(rec.Body.String(), "Check for updates") { t.Fatalf("expected check updates message") } } func TestDockerAgentHandlers_HandleUpdateAll(t *testing.T) { handler, monitor := newDockerAgentHandlers(t, nil) hostID := seedDockerHost(t, monitor) // Ensure the host has at least one container with an update available. _, err := monitor.ApplyDockerReport(agentsdocker.Report{ Agent: agentsdocker.AgentInfo{ ID: "agent-1", Version: "1.0.0", IntervalSeconds: 30, }, Host: agentsdocker.HostInfo{ Hostname: "docker-host", }, Containers: []agentsdocker.Container{{ ID: "container-1", Name: "nginx", Image: "nginx:latest", CreatedAt: time.Now().UTC(), State: "running", Status: "Up", UpdateStatus: &agentsdocker.UpdateStatus{ UpdateAvailable: true, CurrentDigest: "sha256:old", LatestDigest: "sha256:new", LastChecked: time.Now().UTC(), }, }}, Timestamp: time.Now().UTC(), }, nil) if err != nil { t.Fatalf("ApplyDockerReport (with containers): %v", err) } req := httptest.NewRequest(http.MethodPost, "/api/agents/docker/runtimes/"+hostID+"/update-all", nil) rec := httptest.NewRecorder() handler.HandleUpdateAll(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want 200: %s", rec.Code, rec.Body.String()) } if !strings.Contains(rec.Body.String(), "Update all containers") { t.Fatalf("expected update-all message") } } func TestDockerAgentHandlers_HandleUpdateAll_Disabled(t *testing.T) { cfg := &config.Config{DataPath: t.TempDir(), DisableDockerUpdateActions: true} handler, monitor := newDockerAgentHandlers(t, cfg) hostID := seedDockerHost(t, monitor) req := httptest.NewRequest(http.MethodPost, "/api/agents/docker/runtimes/"+hostID+"/update-all", nil) rec := httptest.NewRecorder() handler.HandleUpdateAll(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("status = %d, want 403", rec.Code) } }