146d165c26
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
210 lines
7.6 KiB
Go
210 lines
7.6 KiB
Go
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'")
|
||
}
|
||
}
|