package web import ( "io" "log" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "time" "gitea.dooplex.hu/admin/felhom-hub/internal/store" ) func newTestServer(t *testing.T) (*Server, *store.Store) { t.Helper() st, err := store.New(filepath.Join(t.TempDir(), "t.db"), log.New(io.Discard, "", 0)) if err != nil { t.Fatalf("store.New: %v", err) } t.Cleanup(func() { st.Close() }) // Empty passwordHash → RequireAuth/CSRF are bypassed; we call handlers directly anyway. s := New(st, "", "", "test", 30*time.Minute, log.New(io.Discard, "", 0)) return s, st } // A host-report body carrying vitals, cloudflared, and one rich storage target (with SMART). const testReportJSON = `{ "host": {"cpu_percent": 12.5, "memory_percent": 40.0, "disk_percent": 24.0}, "cloudflared": {"status": "healthy"}, "storage_targets": [ {"name": "felhom-usb", "type": "usb", "role": "user-data", "state": "active", "mount_path": "/mnt/felhom-drives/felhom-usb", "reachable": true, "used_fraction": 0.73, "smart": {"health": "PASSED", "temperature_c": 41, "percentage_used": 3}}, {"name": "local-lvm", "type": "lvmthin", "role": "docker-data", "state": "active", "used_fraction": 0.31, "thin_pool": {"data_used_fraction": 0.31}, "smart": {"health": "PASSED"}} ] }` // TestHostStatus exercises the staleness-band mapping deterministically (the badge must agree // with the HostStalenessChecker: stale after threshold, down after 2×). func TestHostStatus(t *testing.T) { s, _ := newTestServer(t) // staleThreshold = 30m now := time.Now() cases := []struct { name string last *time.Time want string }{ {"never reported", nil, "pending"}, {"fresh", tptr(now.Add(-1 * time.Minute)), "ok"}, {"stale", tptr(now.Add(-45 * time.Minute)), "stale"}, {"down", tptr(now.Add(-3 * time.Hour)), "down"}, } for _, c := range cases { if got := s.hostStatus(c.last); got != c.want { t.Errorf("%s: hostStatus = %q, want %q", c.name, got, c.want) } } } func tptr(t time.Time) *time.Time { return &t } func TestHandleHostsList(t *testing.T) { s, st := newTestServer(t) // Host A: enrolled + reported (→ ONLINE, has vitals + storage). if err := st.UpsertHost(&store.Host{HostID: "demo-felhom-01", CustomerID: "c1", APIKey: "k1"}); err != nil { t.Fatal(err) } if err := st.UpsertGuestFromReport(&store.Guest{GuestID: store.GuestID("demo-felhom-01", 9201), CustomerID: "c1", HostID: "demo-felhom-01", VMID: 9201, DisplayName: "acme", Status: "running", ControllerVersion: "0.87.0"}); err != nil { t.Fatal(err) } if err := st.SaveHostReport("demo-felhom-01", "c1", []byte(testReportJSON), store.HostReportDenorm{ AgentVersion: "0.43.0", CPUPercent: 12.5, MemoryPercent: 40, DiskPercent: 24, GuestTotal: 1, GuestRunning: 1, CloudflaredStatus: "healthy"}); err != nil { t.Fatal(err) } // Host B: enrolled, never reported (→ NO REPORT). if err := st.UpsertHost(&store.Host{HostID: "demo-felhom-02", CustomerID: "c2", APIKey: "k2"}); err != nil { t.Fatal(err) } rr := httptest.NewRecorder() s.handleHostsList(rr, httptest.NewRequest(http.MethodGet, "/hosts", nil)) if rr.Code != http.StatusOK { t.Fatalf("status = %d", rr.Code) } body := rr.Body.String() // Two host rows. if n := strings.Count(body, `window.location='/hosts/`); n != 2 { t.Errorf("want 2 host rows, got %d", n) } // Statuses. if !strings.Contains(body, "ONLINE") { t.Error("reported host should show ONLINE") } if !strings.Contains(body, "NO REPORT") { t.Error("never-reported host should show NO REPORT") } // Worst storage fill = 73% (felhom-usb), not the 31% lvmthin. if !strings.Contains(body, "73%") { t.Error("worst storage fill (73%) not rendered") } // No action buttons on a read-only page. if strings.Contains(strings.ToLower(body), "