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_AllowsNewMonitoredSystemWithCapsRetired // verifies that a new Docker host is accepted without monitored-system caps. func TestDockerAgentHandlers_HandleReport_AllowsNewMonitoredSystemWithCapsRetired(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 accepted because monitored-system caps are retired. 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.StatusOK { t.Fatalf("status = %d, want 200: %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()) } if body := rec.Body.String(); !strings.Contains(body, "Docker / Podman module removed") || strings.Contains(body, "Container runtime") { t.Fatalf("unexpected delete response body: %s", body) } } 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()) } if body := rec.Body.String(); !strings.Contains(body, "Docker / Podman module unhidden") || strings.Contains(body, "Container runtime") { t.Fatalf("unexpected unhide response body: %s", body) } } 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()) } if body := rec.Body.String(); !strings.Contains(body, "Docker / Podman module marked as pending uninstall") || strings.Contains(body, "Container runtime") { t.Fatalf("unexpected pending uninstall response body: %s", body) } } 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()) } if body := rec.Body.String(); !strings.Contains(body, "Docker / Podman module custom display name updated") || strings.Contains(body, "Container runtime") { t.Fatalf("unexpected display-name response body: %s", body) } } func TestDockerAgentHandlers_HandleContainerUpdate_Retired(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.StatusGone { t.Fatalf("status = %d, want 410: %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.StatusGone { t.Fatalf("status = %d, want 410", rec.Code) } } func TestDockerAgentHandlers_HandleContainerUpdate_BlockedBySecurityPosture(t *testing.T) { handler, monitor := newDockerAgentHandlers(t, &config.Config{DataPath: t.TempDir()}) hostID := seedDockerHost(t, monitor) if _, err := monitor.ApplyDockerReport(agentsdocker.Report{ Agent: agentsdocker.AgentInfo{ ID: "agent-1", Version: "1.0.0", IntervalSeconds: 30, }, Host: agentsdocker.HostInfo{ Hostname: "docker-host", Security: &agentsdocker.HostSecurityInfo{ AuthorizationPlugins: []string{"opa"}, }, }, Timestamp: time.Now().UTC(), }, nil); err != nil { t.Fatalf("ApplyDockerReport (with security): %v", err) } 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.StatusGone { t.Fatalf("status = %d, want 410: %s", rec.Code, rec.Body.String()) } if !strings.Contains(rec.Body.String(), "docker_update_retired") { t.Fatalf("expected stable retirement response, got %s", rec.Body.String()) } } 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.StatusGone { t.Fatalf("status = %d, want 410: %s", rec.Code, rec.Body.String()) } if !strings.Contains(rec.Body.String(), "docker_update_all_retired") { t.Fatalf("expected stable update-all retirement response") } } 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.StatusGone { t.Fatalf("status = %d, want 410", rec.Code) } } func TestDockerAgentHandlers_HandleUpdateAll_BlockedBySecurityPosture(t *testing.T) { handler, monitor := newDockerAgentHandlers(t, nil) hostID := seedDockerHost(t, monitor) _, err := monitor.ApplyDockerReport(agentsdocker.Report{ Agent: agentsdocker.AgentInfo{ ID: "agent-1", Version: "1.0.0", IntervalSeconds: 30, }, Host: agentsdocker.HostInfo{ Hostname: "docker-host", Security: &agentsdocker.HostSecurityInfo{ AuthorizationPlugins: []string{"opa"}, }, }, 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 and security): %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.StatusGone { t.Fatalf("status = %d, want 410: %s", rec.Code, rec.Body.String()) } if !strings.Contains(rec.Body.String(), "docker_update_all_retired") { t.Fatalf("expected stable retirement response, got %s", rec.Body.String()) } }