package endpoints import ( "io" "net/http" "net/http/httptest" "testing" "time" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/datastore" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/testhelpers" "github.com/segmentio/encoding/json" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestSummaryCounts(t *testing.T) { type testEndpoint struct { endpointType portainer.EndpointType status portainer.EndpointStatus groupID portainer.EndpointGroupID agentVersion string containerEngine string userTrusted bool lastCheckInDate int64 } currentVersion := portainer.APIVersion tests := []struct { name string endpoints []testEndpoint expectedCounts EnvironmentSummaryCountsResponse }{ { name: "all docker endpoints up", endpoints: []testEndpoint{ {endpointType: portainer.DockerEnvironment, status: portainer.EndpointStatusUp, groupID: 2, agentVersion: currentVersion}, {endpointType: portainer.DockerEnvironment, status: portainer.EndpointStatusUp, groupID: 2, agentVersion: currentVersion}, }, expectedCounts: EnvironmentSummaryCountsResponse{ Total: 2, Up: 2, Down: 0, Outdated: 0, Unassigned: 0, // GroupID 2 has no matching EndpointGroup in the test store. ByGroup: []groupCount{{GroupID: 2, GroupName: "", Count: 2}}, ByPlatformType: platformCounts{Docker: 2}, ByHealth: healthCounts{Up: 2}, }, }, { name: "mix of up and down docker endpoints", endpoints: []testEndpoint{ {endpointType: portainer.DockerEnvironment, status: portainer.EndpointStatusUp, groupID: 2, agentVersion: currentVersion}, {endpointType: portainer.DockerEnvironment, status: portainer.EndpointStatusDown, groupID: 2, agentVersion: currentVersion}, {endpointType: portainer.DockerEnvironment, status: portainer.EndpointStatusDown, groupID: 2, agentVersion: currentVersion}, }, expectedCounts: EnvironmentSummaryCountsResponse{ Total: 3, Up: 1, Down: 2, Outdated: 0, Unassigned: 0, ByGroup: []groupCount{{GroupID: 2, GroupName: "", Count: 3}}, ByPlatformType: platformCounts{Docker: 3}, ByHealth: healthCounts{Down: 2, Up: 1}, }, }, { name: "unassigned endpoints have groupID 1", endpoints: []testEndpoint{ {endpointType: portainer.DockerEnvironment, status: portainer.EndpointStatusUp, groupID: 1, agentVersion: currentVersion}, {endpointType: portainer.DockerEnvironment, status: portainer.EndpointStatusUp, groupID: 2, agentVersion: currentVersion}, }, expectedCounts: EnvironmentSummaryCountsResponse{ Total: 2, Up: 2, Down: 0, Outdated: 0, Unassigned: 1, // GroupID 1 is the default "Unassigned" group; GroupID 2 has no match. ByGroup: []groupCount{{GroupID: 1, GroupName: "Unassigned", Count: 1}, {GroupID: 2, GroupName: "", Count: 1}}, ByPlatformType: platformCounts{Docker: 2}, ByHealth: healthCounts{Up: 2}, }, }, { name: "mixed scenario with docker and kubernetes types", endpoints: []testEndpoint{ {endpointType: portainer.DockerEnvironment, status: portainer.EndpointStatusUp, groupID: 2, agentVersion: currentVersion}, {endpointType: portainer.DockerEnvironment, status: portainer.EndpointStatusDown, groupID: 1, agentVersion: currentVersion}, {endpointType: portainer.KubernetesLocalEnvironment, status: portainer.EndpointStatusUp, groupID: 1, agentVersion: currentVersion}, {endpointType: portainer.AgentOnKubernetesEnvironment, status: portainer.EndpointStatusDown, groupID: 2, agentVersion: currentVersion}, }, expectedCounts: EnvironmentSummaryCountsResponse{ Total: 4, Up: 2, Down: 2, Outdated: 0, Unassigned: 2, ByGroup: []groupCount{{GroupID: 1, GroupName: "Unassigned", Count: 2}, {GroupID: 2, GroupName: "", Count: 2}}, ByPlatformType: platformCounts{Docker: 2, Kubernetes: 2}, ByHealth: healthCounts{Down: 2, Up: 2}, }, }, { name: "outdated endpoints count in both their connection bucket and Outdated", endpoints: []testEndpoint{ {endpointType: portainer.AgentOnDockerEnvironment, status: portainer.EndpointStatusUp, groupID: 2, agentVersion: "2.0.0"}, {endpointType: portainer.AgentOnDockerEnvironment, status: portainer.EndpointStatusDown, groupID: 2, agentVersion: "2.0.0"}, {endpointType: portainer.AgentOnDockerEnvironment, status: portainer.EndpointStatusUp, groupID: 2, agentVersion: currentVersion}, }, expectedCounts: EnvironmentSummaryCountsResponse{ Total: 3, Up: 2, Down: 1, Outdated: 2, Unassigned: 0, ByGroup: []groupCount{{GroupID: 2, GroupName: "", Count: 3}}, ByPlatformType: platformCounts{Docker: 3}, ByHealth: healthCounts{Outdated: 2, Up: 2, Down: 1}, }, }, { name: "azure and podman endpoints counted in platform breakdown", endpoints: []testEndpoint{ {endpointType: portainer.AzureEnvironment, status: portainer.EndpointStatusUp, groupID: 2, agentVersion: currentVersion}, {endpointType: portainer.DockerEnvironment, status: portainer.EndpointStatusUp, groupID: 2, agentVersion: currentVersion, containerEngine: portainer.ContainerEnginePodman}, }, expectedCounts: EnvironmentSummaryCountsResponse{ Total: 2, Up: 2, Down: 0, Outdated: 0, Unassigned: 0, ByGroup: []groupCount{{GroupID: 2, GroupName: "", Count: 2}}, ByPlatformType: platformCounts{Azure: 1, Podman: 1}, ByHealth: healthCounts{Up: 2}, }, }, { name: "untrusted edge endpoints are excluded from counts", endpoints: []testEndpoint{ {endpointType: portainer.DockerEnvironment, status: portainer.EndpointStatusUp, groupID: 2, agentVersion: currentVersion}, {endpointType: portainer.EdgeAgentOnDockerEnvironment, status: portainer.EndpointStatusUp, groupID: 2, agentVersion: currentVersion, userTrusted: false}, }, expectedCounts: EnvironmentSummaryCountsResponse{ Total: 1, Up: 1, Down: 0, Outdated: 0, Unassigned: 0, ByGroup: []groupCount{{GroupID: 2, GroupName: "", Count: 1}}, ByPlatformType: platformCounts{Docker: 1}, ByHealth: healthCounts{Up: 1}, }, }, { name: "trusted edge endpoints classified by heartbeat, not stored status", endpoints: []testEndpoint{ // Recent check-in: heartbeat alive → counted as Up + Heartbeat. {endpointType: portainer.EdgeAgentOnDockerEnvironment, status: portainer.EndpointStatusUp, groupID: 2, agentVersion: currentVersion, userTrusted: true, lastCheckInDate: time.Now().Unix()}, // Stored Up but never checked in: counted as Down. {endpointType: portainer.EdgeAgentOnDockerEnvironment, status: portainer.EndpointStatusUp, groupID: 2, agentVersion: currentVersion, userTrusted: true, lastCheckInDate: 0}, }, expectedCounts: EnvironmentSummaryCountsResponse{ Total: 2, Up: 1, Down: 1, Outdated: 0, Unassigned: 0, ByGroup: []groupCount{{GroupID: 2, GroupName: "", Count: 2}}, ByPlatformType: platformCounts{Docker: 2}, ByHealth: healthCounts{Up: 1, Down: 1, Heartbeat: 1}, }, }, { name: "edge endpoint with unknown version and no check-in is not outdated", endpoints: []testEndpoint{ {endpointType: portainer.EdgeAgentOnDockerEnvironment, status: portainer.EndpointStatusDown, groupID: 2, agentVersion: "", userTrusted: true, lastCheckInDate: 0}, }, expectedCounts: EnvironmentSummaryCountsResponse{ Total: 1, Up: 0, Down: 1, Outdated: 0, Unassigned: 0, ByGroup: []groupCount{{GroupID: 2, GroupName: "", Count: 1}}, ByPlatformType: platformCounts{Docker: 1}, ByHealth: healthCounts{Down: 1}, }, }, { name: "edge endpoint with unknown version but prior check-in is outdated", endpoints: []testEndpoint{ {endpointType: portainer.EdgeAgentOnDockerEnvironment, status: portainer.EndpointStatusDown, groupID: 2, agentVersion: "", userTrusted: true, lastCheckInDate: time.Now().Add(-1 * time.Hour).Unix()}, }, expectedCounts: EnvironmentSummaryCountsResponse{ Total: 1, Up: 0, Down: 1, Outdated: 1, Unassigned: 0, ByGroup: []groupCount{{GroupID: 2, GroupName: "", Count: 1}}, ByPlatformType: platformCounts{Docker: 1}, ByHealth: healthCounts{Down: 1, Outdated: 1}, }, }, { name: "no endpoints returns all zeros", endpoints: []testEndpoint{}, expectedCounts: EnvironmentSummaryCountsResponse{ Total: 0, Up: 0, Down: 0, Outdated: 0, Unassigned: 0, ByGroup: []groupCount{}, ByPlatformType: platformCounts{}, ByHealth: healthCounts{}, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { _, store := datastore.MustNewTestStore(t, true, true) for i, ep := range tt.endpoints { endpoint := &portainer.Endpoint{ ID: portainer.EndpointID(i + 1), Name: "env", Type: ep.endpointType, Status: ep.status, GroupID: ep.groupID, ContainerEngine: ep.containerEngine, UserTrusted: ep.userTrusted, LastCheckInDate: ep.lastCheckInDate, } endpoint.Agent.Version = ep.agentVersion err := store.Endpoint().Create(endpoint) require.NoError(t, err) } err := store.User().Create(&portainer.User{Username: "admin", Role: portainer.AdministratorRole}) require.NoError(t, err) bouncer := testhelpers.NewTestRequestBouncer() handler := NewHandler(bouncer) handler.DataStore = store req := httptest.NewRequest(http.MethodGet, "/endpoints/summary", nil) ctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: 1}) req = req.WithContext(ctx) restrictedCtx := security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true}) req = req.WithContext(restrictedCtx) testhelpers.AddTestSecurityCookie(req, "Bearer dummytoken") rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) require.Equal(t, http.StatusOK, rr.Code, "expected 200 OK") body, err := io.ReadAll(rr.Body) require.NoError(t, err) var counts EnvironmentSummaryCountsResponse err = json.Unmarshal(body, &counts) require.NoError(t, err) assert.Equal(t, tt.expectedCounts.Total, counts.Total, "Total") assert.Equal(t, tt.expectedCounts.Up, counts.Up, "Up") assert.Equal(t, tt.expectedCounts.Down, counts.Down, "Down") assert.Equal(t, tt.expectedCounts.Outdated, counts.Outdated, "Outdated") assert.Equal(t, tt.expectedCounts.Unassigned, counts.Unassigned, "Unassigned") assert.Equal(t, tt.expectedCounts.ByPlatformType, counts.ByPlatformType, "ByPlatformType") assert.Equal(t, tt.expectedCounts.ByHealth, counts.ByHealth, "ByHealth") // ByGroup is derived from map iteration so order is non-deterministic. assert.ElementsMatch(t, tt.expectedCounts.ByGroup, counts.ByGroup, "ByGroup") }) } } func TestSummaryCountsHealthBreakdown(t *testing.T) { _, store := datastore.MustNewTestStore(t, true, true) currentVersion := portainer.APIVersion type ep struct { id portainer.EndpointID name string epType portainer.EndpointType status portainer.EndpointStatus lastCheckIn int64 trusted bool agentVer string } for _, e := range []ep{ {1, "outdated-up", portainer.EdgeAgentOnDockerEnvironment, 0, time.Now().Unix(), true, "2.0.0"}, {2, "outdated-down", portainer.EdgeAgentOnDockerEnvironment, 0, time.Now().Add(-1 * time.Hour).Unix(), true, "2.0.0"}, {3, "up-current", portainer.DockerEnvironment, portainer.EndpointStatusUp, 0, false, currentVersion}, {4, "down-current", portainer.DockerEnvironment, portainer.EndpointStatusDown, 0, false, currentVersion}, } { endpoint := &portainer.Endpoint{ ID: e.id, Name: e.name, Type: e.epType, Status: e.status, LastCheckInDate: e.lastCheckIn, GroupID: 2, UserTrusted: e.trusted, } endpoint.Agent.Version = e.agentVer require.NoError(t, store.Endpoint().Create(endpoint)) } err := store.User().Create(&portainer.User{Username: "admin", Role: portainer.AdministratorRole}) require.NoError(t, err) bouncer := testhelpers.NewTestRequestBouncer() handler := NewHandler(bouncer) handler.DataStore = store req := httptest.NewRequest(http.MethodGet, "/endpoints/summary", nil) ctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: 1}) req = req.WithContext(ctx) restrictedCtx := security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true}) req = req.WithContext(restrictedCtx) testhelpers.AddTestSecurityCookie(req, "Bearer dummytoken") rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) require.Equal(t, http.StatusOK, rr.Code) body, err := io.ReadAll(rr.Body) require.NoError(t, err) var counts EnvironmentSummaryCountsResponse require.NoError(t, json.Unmarshal(body, &counts)) assert.Equal(t, 4, counts.Total) assert.Equal(t, 2, counts.Up, "outdated-up counts in both Up and Outdated") assert.Equal(t, 2, counts.Down, "outdated-down counts in both Down and Outdated") assert.Equal(t, 2, counts.Outdated) assert.Equal(t, healthCounts{ Down: 2, Outdated: 2, Up: 2, Heartbeat: 1, }, counts.ByHealth) } func TestSummaryCountsHeartbeatRefreshedFromInMemoryMap(t *testing.T) { _, store := datastore.MustNewTestStore(t, true, true) endpoint := &portainer.Endpoint{ ID: 1, Name: "stale-edge", Type: portainer.EdgeAgentOnDockerEnvironment, GroupID: 2, UserTrusted: true, LastCheckInDate: time.Now().Add(-1 * time.Hour).Unix(), } endpoint.Agent.Version = portainer.APIVersion require.NoError(t, store.Endpoint().Create(endpoint)) store.Endpoint().UpdateHeartbeat(1) err := store.User().Create(&portainer.User{Username: "admin", Role: portainer.AdministratorRole}) require.NoError(t, err) bouncer := testhelpers.NewTestRequestBouncer() handler := NewHandler(bouncer) handler.DataStore = store req := httptest.NewRequest(http.MethodGet, "/endpoints/summary", nil) ctx := security.StoreTokenData(req, &portainer.TokenData{ID: 1, Username: "admin", Role: 1}) req = req.WithContext(ctx) restrictedCtx := security.StoreRestrictedRequestContext(req, &security.RestrictedRequestContext{UserID: 1, IsAdmin: true}) req = req.WithContext(restrictedCtx) testhelpers.AddTestSecurityCookie(req, "Bearer dummytoken") rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) require.Equal(t, http.StatusOK, rr.Code) body, err := io.ReadAll(rr.Body) require.NoError(t, err) var counts EnvironmentSummaryCountsResponse require.NoError(t, json.Unmarshal(body, &counts)) assert.Equal(t, 1, counts.Total) assert.Equal(t, 1, counts.Up, "in-memory heartbeat should override stale DB LastCheckInDate") assert.Equal(t, 0, counts.Down) assert.Equal(t, healthCounts{Up: 1, Heartbeat: 1}, counts.ByHealth) } func TestResolveEndpointStatus(t *testing.T) { settings := &portainer.Settings{EdgeAgentCheckinInterval: 60} tests := []struct { name string endpoint *portainer.Endpoint expectedStatus int }{ { name: "non-edge endpoint returns stored up status", endpoint: &portainer.Endpoint{ Type: portainer.DockerEnvironment, Status: portainer.EndpointStatusUp, }, expectedStatus: statusUp, }, { name: "non-edge endpoint returns stored down status", endpoint: &portainer.Endpoint{ Type: portainer.DockerEnvironment, Status: portainer.EndpointStatusDown, }, expectedStatus: statusDown, }, { name: "edge endpoint with recent check-in returns heartbeat", endpoint: &portainer.Endpoint{ Type: portainer.EdgeAgentOnDockerEnvironment, Status: portainer.EndpointStatusUp, LastCheckInDate: time.Now().Unix(), }, expectedStatus: statusHeartbeat, }, { name: "edge endpoint with stale check-in returns down regardless of stored status", endpoint: &portainer.Endpoint{ Type: portainer.EdgeAgentOnDockerEnvironment, Status: portainer.EndpointStatusUp, LastCheckInDate: 0, }, expectedStatus: statusDown, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { assert.Equal(t, tt.expectedStatus, resolveEndpointStatus(tt.endpoint, settings)) }) } } func TestIsOutdated(t *testing.T) { currentVersion := portainer.APIVersion tests := []struct { name string version string lastCheckInDate int64 expected bool }{ {name: "empty version with prior check-in is outdated (old agent style)", version: "", lastCheckInDate: time.Now().Unix(), expected: true}, {name: "empty version with no check-in is not outdated (never connected)", version: "", lastCheckInDate: 0, expected: false}, {name: "old version is outdated", version: "2.0.0", expected: true}, {name: "v-prefixed old version is outdated", version: "v2.0.0", expected: true}, {name: "current version is not outdated", version: currentVersion, expected: false}, {name: "v-prefixed current version is not outdated", version: "v" + currentVersion, expected: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Empty-version logic only applies to edge endpoints; use EdgeAgentOnDocker for those cases. epType := portainer.AgentOnDockerEnvironment if tt.version == "" { epType = portainer.EdgeAgentOnDockerEnvironment } ep := &portainer.Endpoint{Type: epType, LastCheckInDate: tt.lastCheckInDate} ep.Agent.Version = tt.version assert.Equal(t, tt.expected, isOutdated(ep)) }) } } func TestParseGroupCounts(t *testing.T) { groups := []portainer.EndpointGroup{ {ID: 1, Name: "Unassigned"}, {ID: 3, Name: "Production"}, {ID: 2, Name: "Staging"}, } tests := []struct { name string counts map[portainer.EndpointGroupID]int expected []groupCount }{ { name: "empty counts returns empty slice", counts: map[portainer.EndpointGroupID]int{}, expected: []groupCount{}, }, { name: "results are sorted by GroupID ascending", counts: map[portainer.EndpointGroupID]int{ 3: 5, 1: 2, 2: 8, }, expected: []groupCount{ {GroupID: 1, GroupName: "Unassigned", Count: 2}, {GroupID: 2, GroupName: "Staging", Count: 8}, {GroupID: 3, GroupName: "Production", Count: 5}, }, }, { name: "group with no matching name gets empty string", counts: map[portainer.EndpointGroupID]int{ 99: 1, }, expected: []groupCount{ {GroupID: 99, GroupName: "", Count: 1}, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := parseGroupCounts(tt.counts, groups) assert.Equal(t, tt.expected, got) }) } } func TestCanonicalizeSemver(t *testing.T) { assert.Equal(t, "v2.0.0", canonicalizeSemver("2.0.0")) assert.Equal(t, "v2.0.0", canonicalizeSemver("v2.0.0")) assert.Empty(t, canonicalizeSemver("")) assert.Empty(t, canonicalizeSemver(" ")) }