package localapi import ( "context" "encoding/json" "io" "log/slog" "net/http" "strings" "testing" "gitea.dooplex.hu/admin/felhom-agent/internal/hub" ) // fakeHostMetrics is a HostMetricsProvider returning a fixed host block (or an error). type fakeHostMetrics struct { host hub.HostMetrics err error } func (f fakeHostMetrics) HostMetricsNow(context.Context) (hub.HostMetrics, error) { return f.host, f.err } func newHostMetricsServer(t *testing.T, hm HostMetricsProvider, sv StorageView) http.Handler { t.Helper() if sv == nil { sv = fakeStorage{} } srv, err := NewServer(Options{ ListenAddr: "127.0.0.1:0", Guests: &fakeGuests{}, Backups: &fakeBackups{}, Store: &fakeStore{}, Storage: sv, Tokens: staticTokens{"A": 8200, "B": 9300}, HostMetrics: hm, Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), }) if err != nil { t.Fatalf("new server: %v", err) } srv.baseCtx = context.Background() return srv.Handler() } func cpuTempPtr(v int) *int { return &v } // A valid token gets a populated host + storage view (host-wide, token-authed). func TestHostMetrics_PopulatedWithValidToken(t *testing.T) { hm := fakeHostMetrics{host: hub.HostMetrics{ Node: "demo-felhom", CPUPercent: 12.5, MemoryTotalBytes: 16 << 30, MemoryUsedBytes: 4 << 30, MemoryPercent: 25, UptimeSeconds: 86400, LoadAvg: []string{"0.10", "0.20", "0.15"}, CPUTempC: cpuTempPtr(46), }} sv := fakeStorage{targets: []hub.StorageTarget{ {Name: "local", Type: hub.StorageTypeLocal, State: hub.StorageStateAttached, Reachable: true, TotalBytes: 100 << 30, UsedBytes: 20 << 30, UsedFraction: 0.2}, }} h := newHostMetricsServer(t, hm, sv) w := do(t, h, "GET", "/host/metrics", "A", "") if w.Code != http.StatusOK { t.Fatalf("GET /host/metrics: got %d, want 200 (body=%s)", w.Code, w.Body.String()) } var env struct { OK bool `json:"ok"` Data HostMetricsResponse `json:"data"` } if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil { t.Fatalf("decode: %v", err) } if !env.OK { t.Fatal("ok=false") } if env.Data.VMID != 8200 { t.Errorf("vmid = %d, want 8200 (token's guest)", env.Data.VMID) } if env.Data.Host.Node != "demo-felhom" || env.Data.Host.CPUPercent != 12.5 { t.Errorf("host = %+v", env.Data.Host) } if env.Data.Host.CPUTempC == nil || *env.Data.Host.CPUTempC != 46 { t.Errorf("cpu_temp_c = %v, want 46", env.Data.Host.CPUTempC) } if len(env.Data.StorageTargets) != 1 || env.Data.StorageTargets[0].Name != "local" { t.Errorf("storage targets = %+v", env.Data.StorageTargets) } } // Null cpu_temp_c marshals as JSON null (the controller renders "n/a"). func TestHostMetrics_NullTempSerializes(t *testing.T) { hm := fakeHostMetrics{host: hub.HostMetrics{Node: "n", LoadAvg: []string{}, CPUTempC: nil}} h := newHostMetricsServer(t, hm, nil) w := do(t, h, "GET", "/host/metrics", "A", "") if w.Code != http.StatusOK { t.Fatalf("got %d, want 200", w.Code) } // The raw JSON must contain `"cpu_temp_c":null` (a stable key, not omitted). if got := w.Body.String(); !strings.Contains(got, `"cpu_temp_c":null`) { t.Errorf("body missing cpu_temp_c null: %s", got) } } // No token → 401, and the collector is never invoked. func TestHostMetrics_Requires401WithoutToken(t *testing.T) { called := false hm := callbackHostMetrics{fn: func() { called = true }} h := newHostMetricsServer(t, hm, nil) if w := do(t, h, "GET", "/host/metrics", "", ""); w.Code != http.StatusUnauthorized { t.Fatalf("absent token: got %d, want 401", w.Code) } if w := do(t, h, "GET", "/host/metrics", "bogus", ""); w.Code != http.StatusUnauthorized { t.Fatalf("unknown token: got %d, want 401", w.Code) } if called { t.Fatal("host metrics collected despite failed auth") } } // When no provider is wired the endpoint reports "not configured" (503), not a crash. func TestHostMetrics_NotConfigured(t *testing.T) { h := newHostMetricsServer(t, nil, nil) if w := do(t, h, "GET", "/host/metrics", "A", ""); w.Code != http.StatusServiceUnavailable { t.Fatalf("got %d, want 503 (not configured)", w.Code) } } // A cross-guest probe (?vmid=other) is refused 403 even though the data is host-wide — the // self-scoping invariant is uniform across endpoints. func TestHostMetrics_CrossGuestQueryRefused(t *testing.T) { hm := fakeHostMetrics{host: hub.HostMetrics{Node: "n", LoadAvg: []string{}}} h := newHostMetricsServer(t, hm, nil) if w := do(t, h, "GET", "/host/metrics?vmid=9300", "A", ""); w.Code != http.StatusForbidden { t.Fatalf("cross-guest query: got %d, want 403", w.Code) } } // callbackHostMetrics records that the collector was invoked (to assert it is NOT on a 401). type callbackHostMetrics struct{ fn func() } func (c callbackHostMetrics) HostMetricsNow(context.Context) (hub.HostMetrics, error) { c.fn() return hub.HostMetrics{LoadAvg: []string{}}, nil }