Files
felhom.eu/hub/internal/web/hosts_test.go
T

210 lines
7.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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), "<button") {
t.Error("hosts list must not contain action buttons")
}
}
func TestHandleHostDetail(t *testing.T) {
s, st := newTestServer(t)
const secretKey = "SUPER-SECRET-HOST-KEY-42"
if err := st.UpsertHost(&store.Host{HostID: "demo-felhom-01", CustomerID: "c1", APIKey: secretKey}); 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", CloudflaredStatus: "healthy"}); err != nil {
t.Fatal(err)
}
// DR + escrow present (escrow row must exist before the DR bundle UPDATE).
if err := st.SaveHostEscrow("demo-felhom-01", []byte("opaque-escrow"), "fp", "posture", "2026-06-01T00:00:00Z", ""); err != nil {
t.Fatal(err)
}
if err := st.SaveHostDRBundle("demo-felhom-01", []byte("opaque-identity"), `{"v":1}`); err != nil {
t.Fatal(err)
}
rr := httptest.NewRecorder()
s.handleHostDetail(rr, httptest.NewRequest(http.MethodGet, "/hosts/demo-felhom-01", nil), "demo-felhom-01")
if rr.Code != http.StatusOK {
t.Fatalf("status = %d", rr.Code)
}
body := rr.Body.String()
for _, want := range []string{
"demo-felhom-01", // identity
"9201", // guest
"0.87.0", // controller version
"felhom-usb", // storage target
"PASSED", // SMART health
"41°C", // SMART temperature
"user-data", // role
"/customers/c1", // customer cross-link
} {
if !strings.Contains(body, want) {
t.Errorf("detail body missing %q", want)
}
}
// DR + escrow presence surfaced (booleans, not blobs).
if !strings.Contains(body, "DR / Backup") || strings.Count(body, "present") < 2 {
t.Error("DR/escrow presence not both shown")
}
// SECURITY: the host api_key must never be rendered.
if strings.Contains(body, secretKey) {
t.Errorf("SECRET LEAK: detail page rendered the host api_key")
}
// v0.46.0: the ONLY host actions are the two log-bundle request forms (the page is
// otherwise still read-only — no destructive/host-mutating buttons).
// v0.47.0: this fixture host is ONLINE (report just saved), so the stale-host
// danger-zone card must NOT render for it — this pin now doubles as the
// "delete hidden for online hosts" proof (the stale case: TestHostDetail_DangerCardForStaleOnly).
if got := strings.Count(strings.ToLower(body), "<button"); got != 2 {
t.Errorf("host detail has %d buttons, want exactly the 2 log-request buttons", got)
}
if strings.Count(body, `action="/hosts/demo-felhom-01/request-logs"`) != 2 {
t.Error("the request-logs forms are missing — every button must be a log-bundle request")
}
// The Diagnostics section renders with its honest latency hint.
if !strings.Contains(body, "Diagnostics") || !strings.Contains(body, "72 h") {
t.Error("Diagnostics log-bundle section missing")
}
}
func TestHandleHostDetail_Unknown(t *testing.T) {
s, _ := newTestServer(t)
rr := httptest.NewRecorder()
s.handleHostDetail(rr, httptest.NewRequest(http.MethodGet, "/hosts/nope", nil), "nope")
if rr.Code != http.StatusNotFound {
t.Errorf("unknown host: status = %d, want 404", rr.Code)
}
}
func TestHandleHostDetail_NoReport(t *testing.T) {
s, st := newTestServer(t)
if err := st.UpsertHost(&store.Host{HostID: "fresh-host", CustomerID: "c9", APIKey: "k9"}); err != nil {
t.Fatal(err)
}
rr := httptest.NewRecorder()
s.handleHostDetail(rr, httptest.NewRequest(http.MethodGet, "/hosts/fresh-host", nil), "fresh-host")
if rr.Code != http.StatusOK {
t.Fatalf("status = %d", rr.Code)
}
body := rr.Body.String()
if !strings.Contains(body, "waiting for first report") {
t.Error("no-report host should render 'waiting for first report'")
}
}