Files
pulse/internal/api/router_state_test.go
T
pulse-triage[bot] c253495045 feat: add fleet health home
Change-source: pulse-maintainer
2026-09-01 01:02:17 +01:00

319 lines
11 KiB
Go

package api
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rcourtman/pulse-go-rewrite/pkg/auth"
"github.com/stretchr/testify/assert"
)
func TestRouter_HandleState_MockIsolation(t *testing.T) {
// Enable mock mode to control GetState output
setMockModeForTest(t, true)
dataPath := t.TempDir()
hp, _ := auth.HashPassword("password")
cfg := &config.Config{
DataPath: dataPath,
MultiTenantEnabled: true,
AuthUser: "admin",
AuthPass: hp,
}
// Initialize persistent stores to avoid permission issues with /etc/pulse
InitSessionStore(dataPath)
InitCSRFStore(dataPath)
// Create a router with a real monitor
// Since monitor.ReadSnapshot() checks IsMockEnabled(), it will return mock data.
monitor, _ := monitoring.New(cfg)
router := &Router{
config: cfg,
monitor: monitor,
persistence: config.NewConfigPersistence(dataPath),
}
t.Run("basic state access", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/state", nil)
// Set credentials for CheckAuth (Basic Auth)
req.SetBasicAuth("admin", "password")
w := httptest.NewRecorder()
router.handleState(w, req)
assert.Equal(t, http.StatusOK, w.Code)
var payload map[string]any
err := json.Unmarshal(w.Body.Bytes(), &payload)
assert.NoError(t, err)
// Verify v6 contract: legacy per-type arrays are omitted from /api/state.
for _, key := range []string{"nodes", "vms", "containers", "dockerHosts", "hosts", "storage"} {
if _, ok := payload[key]; ok {
t.Fatalf("expected %q to be omitted from /api/state payload", key)
}
}
var state models.StateFrontend
err = json.Unmarshal(w.Body.Bytes(), &state)
assert.NoError(t, err)
assert.Greater(t, state.LastUpdate, int64(0))
assert.NotNil(t, state.RecentlyResolved)
assert.NotNil(t, state.Metrics)
assert.NotNil(t, state.ConnectionHealth)
metricsPayload, ok := payload["metrics"].([]any)
if !ok {
t.Fatalf("expected metrics payload to be an array, got %T", payload["metrics"])
}
if metricsPayload == nil {
t.Fatal("expected metrics payload array to be non-nil")
}
if _, ok := payload["performance"].(map[string]any); !ok {
t.Fatalf("expected performance payload to be an object, got %T", payload["performance"])
}
if _, ok := payload["stats"].(map[string]any); !ok {
t.Fatalf("expected stats payload to be an object, got %T", payload["stats"])
}
if _, ok := payload["resources"].([]any); !ok {
t.Fatalf("expected resources payload to be an array, got %T", payload["resources"])
}
if _, ok := payload["connectedInfrastructure"].([]any); !ok {
t.Fatalf("expected connectedInfrastructure payload to be an array, got %T", payload["connectedInfrastructure"])
}
})
t.Run("invalid method", func(t *testing.T) {
req := httptest.NewRequest("POST", "/api/state", nil)
w := httptest.NewRecorder()
router.handleState(w, req)
assert.Equal(t, http.StatusMethodNotAllowed, w.Code)
})
t.Run("unauthorized", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/state", nil)
w := httptest.NewRecorder()
router.handleState(w, req)
assert.Equal(t, http.StatusUnauthorized, w.Code)
})
}
func TestRouterHandleStatePreservesNumericIdleRatesAndOmitsUnknownRates(t *testing.T) {
dataPath := t.TempDir()
hp, err := auth.HashPassword("password")
if err != nil {
t.Fatalf("hash password: %v", err)
}
cfg := &config.Config{
DataPath: dataPath,
AuthUser: "admin",
AuthPass: hp,
}
InitSessionStore(dataPath)
InitCSRFStore(dataPath)
monitor, state, _ := newTestMonitor(t)
state.VMs = []models.VM{
{
ID: "site-a:pve-a:100",
VMID: 100,
Name: "idle-vm",
Node: "pve-a",
Instance: "site-a",
Status: "running",
IORateValidity: models.IORateValidity{
Explicit: true,
DiskRead: true,
DiskWrite: true,
NetworkIn: true,
NetworkOut: true,
},
},
{
ID: "site-a:pve-a:101",
VMID: 101,
Name: "unknown-vm",
Node: "pve-a",
Instance: "site-a",
Status: "running",
IORateValidity: models.IORateValidity{
Explicit: true,
},
},
}
syncTestResourceStore(t, monitor, state)
router := &Router{config: cfg, monitor: monitor}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/state", nil)
req.SetBasicAuth("admin", "password")
router.handleState(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("/api/state status = %d, body=%s", rec.Code, rec.Body.String())
}
var payload struct {
Resources []models.ResourceFrontend `json:"resources"`
}
if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil {
t.Fatalf("decode /api/state: %v", err)
}
byName := make(map[string]models.ResourceFrontend, len(payload.Resources))
for _, resource := range payload.Resources {
byName[resource.Name] = resource
}
idle := byName["idle-vm"]
if idle.DiskIO == nil || idle.DiskIO.ReadRate != 0 || idle.DiskIO.WriteRate != 0 {
t.Fatalf("valid idle rates were not emitted as numeric zero: %+v", idle.DiskIO)
}
if unknown := byName["unknown-vm"]; unknown.DiskIO != nil {
t.Fatalf("unknown rates projected a disk I/O object: %+v", unknown.DiskIO)
}
if strings.Contains(rec.Body.String(), `"readRate":null`) || strings.Contains(rec.Body.String(), `"writeRate":null`) {
t.Fatalf("/api/state emitted unstable null rates: %s", rec.Body.String())
}
}
func TestRouter_HandleStateSummary(t *testing.T) {
dataPath := t.TempDir()
hp, _ := auth.HashPassword("password")
cfg := &config.Config{
DataPath: dataPath,
AuthUser: "admin",
AuthPass: hp,
}
InitSessionStore(dataPath)
InitCSRFStore(dataPath)
monitor, state, _ := newTestMonitor(t)
lastUpdate := time.Date(2026, 5, 24, 10, 11, 12, 0, time.UTC)
state.Nodes = []models.Node{{ID: "node-1"}, {ID: "node-2"}}
state.VMs = []models.VM{{ID: "vm-1"}}
state.Containers = []models.Container{{ID: "ct-1"}, {ID: "ct-2"}, {ID: "ct-3"}}
state.ActiveAlerts = []models.Alert{{ID: "alert-1"}}
state.DockerHosts = []models.DockerHost{
{
ID: "docker-1",
Hostname: "docker.local",
DisplayName: "Docker Host",
UptimeSeconds: 3600,
CPUUsage: 12.5,
Containers: []models.DockerContainer{
{ID: "container-1"},
{ID: "container-2"},
},
},
}
state.LastUpdate = lastUpdate
syncTestResourceStore(t, monitor, state)
router := &Router{config: cfg, monitor: monitor}
req := httptest.NewRequest(http.MethodGet, "/api/state/summary", nil)
req.SetBasicAuth("admin", "password")
rec := httptest.NewRecorder()
router.handleStateSummary(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
var summary stateSummaryResponse
err := json.Unmarshal(rec.Body.Bytes(), &summary)
assert.NoError(t, err)
assert.Equal(t, 1, summary.ActiveAlerts)
assert.Equal(t, 2, summary.Nodes)
assert.Equal(t, 1, summary.VMs)
assert.Equal(t, 3, summary.Containers)
assert.Equal(t, lastUpdate, summary.LastUpdate)
if assert.Len(t, summary.DockerHosts, 1) {
assert.Equal(t, "Docker Host", summary.DockerHosts[0].Name)
assert.Equal(t, 2, summary.DockerHosts[0].Containers)
assert.Equal(t, int64(3600), summary.DockerHosts[0].UptimeSeconds)
assert.Equal(t, 12.5, summary.DockerHosts[0].CPUUsagePercent)
}
if rec.Body.Len() >= 5*1024 {
t.Fatalf("expected /api/state/summary body under 5 KiB, got %d bytes", rec.Body.Len())
}
var payload map[string]any
err = json.Unmarshal(rec.Body.Bytes(), &payload)
assert.NoError(t, err)
for _, key := range []string{"resources", "metrics", "performance", "stats", "connectedInfrastructure", "pveTagStyles", "pveTagColors"} {
if _, ok := payload[key]; ok {
t.Fatalf("expected %q to be omitted from /api/state/summary payload", key)
}
}
}
func TestBuildStateSummaryCountsSnapshotResources(t *testing.T) {
lastUpdate := time.Date(2026, 5, 24, 10, 11, 12, 0, time.UTC)
snapshot := models.StateSnapshot{
ActiveAlerts: []models.Alert{{ID: "alert-1"}},
Nodes: []models.Node{{ID: "node-1"}, {ID: "node-2"}},
VMs: []models.VM{{ID: "vm-1"}},
Containers: []models.Container{{ID: "ct-1"}, {ID: "ct-2"}, {ID: "ct-3"}},
DockerHosts: []models.DockerHost{{
ID: "docker-1",
Hostname: "docker.local",
DisplayName: "Docker Host",
CustomDisplayName: "Custom Docker Host",
UptimeSeconds: 3600,
CPUUsage: 12.5,
Containers: []models.DockerContainer{{ID: "container-1"}, {ID: "container-2"}},
}},
LastUpdate: lastUpdate,
}
registry := unifiedresources.NewRegistry(nil)
registry.IngestSnapshot(snapshot)
summary := buildStateSummary(registry, registry.List(), snapshot.ActiveAlerts, snapshot.LastUpdate)
assert.Equal(t, 1, summary.ActiveAlerts)
assert.Equal(t, 2, summary.Nodes)
assert.Equal(t, 1, summary.VMs)
assert.Equal(t, 3, summary.Containers)
assert.Equal(t, lastUpdate, summary.LastUpdate)
if assert.Len(t, summary.DockerHosts, 1) {
assert.Equal(t, "Custom Docker Host", summary.DockerHosts[0].Name)
assert.Equal(t, 2, summary.DockerHosts[0].Containers)
assert.Equal(t, int64(3600), summary.DockerHosts[0].UptimeSeconds)
assert.Equal(t, 12.5, summary.DockerHosts[0].CPUUsagePercent)
}
}
func TestSummarizeResourceHealthKeepsStaleVisibleAndCapsAttention(t *testing.T) {
resources := make([]unifiedresources.Resource, 0, 54)
resources = append(resources,
unifiedresources.Resource{ID: "critical", Name: "Critical", Type: unifiedresources.ResourceTypeAgent, Health: &unifiedresources.ResourceHealth{Verdict: unifiedresources.HealthCritical, Reasons: []unifiedresources.ResourceHealthReason{{Code: "offline"}}}},
unifiedresources.Resource{ID: "stale", Name: "Stale", Type: unifiedresources.ResourceTypeAgent, Health: &unifiedresources.ResourceHealth{Verdict: unifiedresources.HealthStale, Reasons: []unifiedresources.ResourceHealthReason{{Code: "telemetry_stale"}}}},
unifiedresources.Resource{ID: "healthy", Name: "Healthy", Type: unifiedresources.ResourceTypeAgent, Health: &unifiedresources.ResourceHealth{Verdict: unifiedresources.HealthOK, Reasons: []unifiedresources.ResourceHealthReason{}}},
unifiedresources.Resource{ID: "off", Name: "Off", Type: unifiedresources.ResourceTypeVM, Health: &unifiedresources.ResourceHealth{Verdict: unifiedresources.HealthOff, Reasons: []unifiedresources.ResourceHealthReason{{Code: "powered_off"}}}},
)
for i := 0; i < 50; i++ {
resources = append(resources, unifiedresources.Resource{
ID: fmt.Sprintf("unknown-%02d", i), Name: fmt.Sprintf("Unknown %02d", i), Type: unifiedresources.ResourceTypeAgent,
Health: &unifiedresources.ResourceHealth{Verdict: unifiedresources.HealthUnknown, Reasons: []unifiedresources.ResourceHealthReason{{Code: "telemetry_missing"}}},
})
}
counts, attention := summarizeResourceHealth(resources)
assert.Equal(t, stateSummaryVerdicts{OK: 1, Critical: 1, Stale: 1, Off: 1, Unknown: 50}, counts)
assert.Len(t, attention, stateSummaryAttentionLimit)
assert.Equal(t, "critical", attention[0].ID)
assert.Equal(t, "stale", attention[1].ID)
payload, err := json.Marshal(stateSummaryResponse{Verdicts: counts, Attention: attention})
assert.NoError(t, err)
assert.Less(t, len(payload), 5*1024)
}