36c72138f1
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").
195 lines
7.1 KiB
Go
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")
|
|
}
|
|
})
|
|
}
|