Files
portainer/api/http/handler/endpoints/endpoint_summary_counts_test.go

498 lines
19 KiB
Go

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(" "))
}