Files
felhom.eu/hub/internal/web/hosts_test.go
T
admin e35b1ae0e6 hub v0.46.0: observability pass — per-box log pulls, bundle custody, 72h TTL + secret gate
log_bundle_requests + log_bundles store (gzip, newest-3, 72h TTL purged on the
60s sweep); SaveLogBundle secret gate fail-closed (blocked flag row, no payload;
REDACTED/checksums pass). Report ACK gains controller_log_requested + ingests
controller_log_tail; heartbeat envelope gains log_tail_requested + ingests
log_tail (consume-once on arrival; pre-0.83 agents stay visibly pending). Host
detail Diagnostics section: request buttons (controller/agent), state rows with
honest latency hints, View/Download endpoint. Red-proofs: gate disabled and
clear-on-arrival removed both FAIL their tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
2026-07-11 16:57:47 +02:00

207 lines
7.3 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).
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'")
}
}