mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 18:45:53 +00:00
c253495045
Change-source: pulse-maintainer
319 lines
11 KiB
Go
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)
|
|
}
|