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") } }) }