Files
felhom.eu/hub/internal/web/rollup_test.go
T
admin 36c72138f1 hub: dead-host roll-up honesty - customer status folds worst expected host
Customer status (dashboard row, /configs list, detail header + strip) is
now worst(controllerDerived, hostStatusOf(each expected host)) via the ONE
staleness definition (Server.hostStatus, hosts.go - shared with the
HostStalenessChecker; no second threshold). Any host down/stale caps the
customer at WARN with a cause chip naming the host ("host down: <id>");
pending (never-reported) hosts worsen only once the customer has reported
(onboarding exclusion). The three previously-inlined controller-status
chains collapse into controllerStatus() (rollup.go). Display + derivation
only - checker alerting untouched.

Live shape pinned (drill-1 / Peti cluster): host down 23h + controller
report minutes old rendered a GREEN row - TestRollup_DeadHostMasking now
fails that exact outcome. Red-proof: short-circuiting foldHostStatus to
controller-only flips C + two D subtests red ("dashboard row is GREEN
over a 23h-dead host").
2026-07-13 14:52:04 +02:00

195 lines
7.1 KiB
Go

package web
// v0.53.0 dead-host roll-up honesty (drill-1 masking observation; operator ruling 2026-07-13).
// Scenario C is the LIVE Peti-cluster shape: a host down 23h while the controller keeps
// reporting through the internet — pre-fix the customer row rendered GREEN (the wrong outcome
// these tests pin). Scenario D bounds the fold: all-ok is a no-regression pass-through, a stale
// host warns with its own chip, and pending hosts worsen only after onboarding.
// RED-PROOF (§10 C): short-circuiting foldHostStatus to `return base, ""` (controller-only
// derivation) → the C assertions fail with the green row / missing chip visible.
import (
"database/sql"
"fmt"
"io"
"log"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// newRollupServer builds a server whose store DB path is known, so tests can backdate host
// reports over a second connection (the monitor host_staleness_test pattern).
func newRollupServer(t *testing.T) (*Server, *store.Store, *sql.DB) {
t.Helper()
path := filepath.Join(t.TempDir(), "t.db")
st, err := store.New(path, log.New(io.Discard, "", 0))
if err != nil {
t.Fatalf("store.New: %v", err)
}
t.Cleanup(func() { st.Close() })
db, err := sql.Open("sqlite", path)
if err != nil {
t.Fatalf("sql.Open: %v", err)
}
t.Cleanup(func() { db.Close() })
// staleThreshold 30m → host "stale" past 30m, "down" past 60m (the single definition).
s := New(st, "", "", "test", 30*time.Minute, log.New(io.Discard, "", 0))
return s, st, db
}
func backdateHost(t *testing.T, db *sql.DB, hostID string, minutesAgo int) {
t.Helper()
if _, err := db.Exec(`UPDATE hosts SET last_report_at = datetime('now', ?) WHERE host_id = ?`,
fmt.Sprintf("-%d minutes", minutesAgo), hostID); err != nil {
t.Fatal(err)
}
}
func renderDashboard(t *testing.T, s *Server) string {
t.Helper()
rr := httptest.NewRecorder()
s.handleDashboard(rr, httptest.NewRequest("GET", "/", nil))
if rr.Code != 200 {
t.Fatalf("dashboard = %d", rr.Code)
}
return rr.Body.String()
}
func renderCustomer(t *testing.T, s *Server, customerID string) string {
t.Helper()
rr := httptest.NewRecorder()
s.handleCustomerUnified(rr, httptest.NewRequest("GET", "/customers/"+customerID, nil), customerID)
if rr.Code != 200 {
t.Fatalf("customer detail = %d", rr.Code)
}
return rr.Body.String()
}
// Scenario C — the Peti shape: proxmox1 down 23h, fresh controller report → the customer row
// must read WARN with the cause chip naming the host; the detail header must say WHICH host.
// WRONG outcome (pre-fix): a green row.
func TestRollup_DeadHostMasking(t *testing.T) {
s, st, db := newRollupServer(t)
if err := st.SaveCustomerConfig(&store.CustomerConfig{
CustomerID: "acme", CustomerName: "Acme", APIKey: "k", RetrievalPassword: "p",
}); err != nil {
t.Fatal(err)
}
// Fresh controller report (the guest reports hub-direct, independent of the host agent).
if err := st.SaveReport("acme", []byte(`{"customer_id":"acme","customer_name":"Acme"}`)); err != nil {
t.Fatal(err)
}
// The host: reported once, then silent for 23h → "down" by THE definition.
if err := st.UpsertHost(&store.Host{HostID: "proxmox1", CustomerID: "acme", APIKey: "hk"}); err != nil {
t.Fatal(err)
}
if err := st.SaveHostReport("proxmox1", "acme", []byte(`{}`), store.HostReportDenorm{}); err != nil {
t.Fatal(err)
}
backdateHost(t, db, "proxmox1", 23*60)
body := renderDashboard(t, s)
if !strings.Contains(body, "host down: proxmox1") {
t.Error("dashboard row lacks the cause chip \"host down: proxmox1\"")
}
if strings.Contains(body, "status-badge-ok") {
t.Error("dashboard row is GREEN over a 23h-dead host — the exact masking bug")
}
if !strings.Contains(body, `status-badge-warn">host down: proxmox1`) {
t.Error("cause chip not rendered as a warn badge")
}
// Detail header: WHICH host.
detail := renderCustomer(t, s, "acme")
if !strings.Contains(detail, "host down: proxmox1") {
t.Error("customer detail header does not name the down host")
}
}
// Scenario D — boundaries: all-ok pass-through (no regression), stale-host warn chip, and the
// onboarding rule for pending hosts.
func TestRollup_Boundaries(t *testing.T) {
t.Run("all hosts ok leaves controller status untouched", func(t *testing.T) {
s, st, _ := newRollupServer(t)
if err := st.SaveReport("acme", []byte(`{"customer_id":"acme"}`)); err != nil {
t.Fatal(err)
}
if err := st.UpsertHost(&store.Host{HostID: "h-ok", CustomerID: "acme", APIKey: "hk"}); err != nil {
t.Fatal(err)
}
if err := st.SaveHostReport("h-ok", "acme", []byte(`{}`), store.HostReportDenorm{}); err != nil {
t.Fatal(err)
}
body := renderDashboard(t, s)
if !strings.Contains(body, "status-badge-ok") {
t.Error("healthy customer + healthy host must stay OK")
}
if strings.Contains(body, "host down") || strings.Contains(body, "host stale") || strings.Contains(body, "host pending") {
t.Error("cause chip rendered with every host ok")
}
})
t.Run("single stale host caps at warn with stale chip", func(t *testing.T) {
s, st, db := newRollupServer(t)
if err := st.SaveReport("acme", []byte(`{"customer_id":"acme"}`)); err != nil {
t.Fatal(err)
}
if err := st.UpsertHost(&store.Host{HostID: "h-stale", CustomerID: "acme", APIKey: "hk"}); err != nil {
t.Fatal(err)
}
if err := st.SaveHostReport("h-stale", "acme", []byte(`{}`), store.HostReportDenorm{}); err != nil {
t.Fatal(err)
}
backdateHost(t, db, "h-stale", 45) // between stale (30m) and down (60m)
body := renderDashboard(t, s)
if !strings.Contains(body, "host stale: h-stale") {
t.Error("stale-host cause chip missing")
}
if strings.Contains(body, "status-badge-ok") {
t.Error("customer stayed green over a stale host")
}
})
t.Run("pending host during onboarding does not worsen", func(t *testing.T) {
s, st, _ := newRollupServer(t)
// Config-only customer (never reported) + freshly-minted host (never reported).
if err := st.SaveCustomerConfig(&store.CustomerConfig{
CustomerID: "newbie", CustomerName: "Newbie", APIKey: "k", RetrievalPassword: "p",
}); err != nil {
t.Fatal(err)
}
if err := st.UpsertHost(&store.Host{HostID: "h-new", CustomerID: "newbie", APIKey: "hk"}); err != nil {
t.Fatal(err)
}
body := renderDashboard(t, s)
if !strings.Contains(body, "status-badge-pending") {
t.Error("onboarding customer must render PENDING")
}
if strings.Contains(body, "host pending") {
t.Error("a never-reported host worsened a never-reported customer (onboarding exclusion violated)")
}
})
t.Run("pending host after onboarding worsens", func(t *testing.T) {
s, st, _ := newRollupServer(t)
if err := st.SaveReport("acme", []byte(`{"customer_id":"acme"}`)); err != nil {
t.Fatal(err)
}
if err := st.UpsertHost(&store.Host{HostID: "h-silent", CustomerID: "acme", APIKey: "hk"}); err != nil {
t.Fatal(err)
}
body := renderDashboard(t, s)
if !strings.Contains(body, "host pending: h-silent") {
t.Error("a never-reported host must worsen a LIVE customer (post-onboarding)")
}
if strings.Contains(body, "status-badge-ok") {
t.Error("customer stayed green over a never-reported host")
}
})
}