Files
felhom.eu/hub/internal/web/hosts_test.go
T
admin 87af859fc3 hub: operator actions for a box (D1, R-314/R-279/R-177, decision 185)
Host page "Operator Actions" card: run off-site backup now, run a check now
(fixed job list), stop / extend (1-30 days) a deletion countdown. POST
/hosts/{id}/operator-action validates against the CLOSED list before
storing (unknown -> 400, no row), stores operator_actions(id, customer_id,
action, arg, requested_at, requested_by, done_at, outcome, message), logs
who pressed (channel + address) and bumps the box's intent. The report ACK
lists pending rows as operator_actions until the box's
operator_action_results closes them (matched on id AND reporting
customer); each closed row becomes a hub-minted operator_action event
(stored, never dispatched). Unanswered after 24 h: expired. A customer
RESET cancels pending rows. Wire gate: new root + field-by-field mirror
(controller report.OperatorAction) — needs the controller commit first.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-08 15:17:06 +02:00

216 lines
8.0 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")); 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).
// Decision 185 (2026-10-08): plus the four operator-action buttons — a closed list, none of which
// deletes or mutates the host (TestOperatorActions_* pin what they may do). Every OTHER button
// remains absent: 2 log requests + 4 operator actions, and the 4 all post to /operator-action.
if got := strings.Count(strings.ToLower(body), "<button"); got != 6 {
t.Errorf("host detail has %d buttons, want exactly the 2 log-request + 4 operator-action buttons", got)
}
if got := strings.Count(body, `action="/hosts/demo-felhom-01/operator-action"`); got != 4 {
t.Errorf("operator-action forms = %d, want 4", 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'")
}
}