Files
pulse/internal/api/router_state_test.go
T
rcourtman f48c806718 Preserve observed Docker storage evidence
Missing block I/O and container image sizes could become false evidence
for diagnosis. Preserve per-direction counter presence and measured zero
through collection, resource conversion and browser rendering. Separate
new observed history from ambiguous retained disk series without deleting
old rows or changing public metric names.

Keep partial host rates distinct and persist a newly enabled Disk I/O
column across the first preference reload.
2026-09-06 12:45:40 +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 == nil || idle.DiskIO.WriteRate == 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)
}