Files
felhom.eu/hub/internal/web/hosts_test.go
T
admin 2289fc907c hub v0.27.0: Hosts page — read-only fleet view (audit F-M1)
New Hosts nav section: a fleet list (/hosts) + per-host detail (/hosts/{id}),
read-only (GET only, no host actions). Surfaces identity, agent version,
online/stale status (reusing the HostStalenessChecker threshold), guests,
vitals, storage targets with SMART/thin-pool, and DR/escrow presence.

- store: new ListGuestsForHost reader (reality cols only; omits api_key/
  desired_spec_json) + scanGuest helper.
- web: handleHostsList + handleHostDetail (hosts.go); hosts.html +
  host_detail.html; Hosts nav link on every page; timeAgoPtr helper; routes.
- tests: store getter, both handlers, no-secret (api_key) assertion, 404,
  no-report empty state, status-band mapping.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 06:10:02 +02:00

199 lines
6.8 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")
}
// Read-only: no host action buttons.
if strings.Contains(strings.ToLower(body), "<button") {
t.Error("host detail must not contain action buttons")
}
}
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'")
}
}