Files
felhom-controller/controller/internal/agentapi/guestmemory_test.go
T
admin 3286c7faaf v0.143.0 — guest RAM resize UI (R-24) — MinAgent: 0.90.0
The customer sees the guest's current memory + allowed range on the Rendszer settings
page and resizes it. The controller proxies + maps the agent's machine code to Hungarian;
the agent (felhom-agent v0.90.0) enforces every bound and applies live (no reboot).

agentapi: GuestMemory + ResizeMemory; ruled 412 -> *MemoryRefusedError (code+bounds);
pre-0.90 agent 404 -> typed *StatusError. Capability: FeatureGuestMemoryResize +
featureMinAgent 0.90.0 + a featureProbes row (type-asserts GuestMemory so the shared
SupportProber/netAgent are untouched).

UI (internal/web/system_memory_handlers.go, settings_system.html): "Szerver memoria (RAM)"
card + number input; POST /api/system/memory/resize -> capability gate -> agent -> flash.
JS confirm only on shrink. Code->Hungarian map; agent-outdated hides the control;
agent-unreachable falls back to the guest's /proc/meminfo. Agent English never shown raw.

Tests: agentapi (decode, 404, refusal-code, capability table) + web handler (success/
below_usage_floor/agent_outdated). Gates + go build/vet/test all pass.
2026-07-17 19:10:00 +02:00

133 lines
4.8 KiB
Go

package agentapi
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// memProber satisfies the SupportProber contract (NetVerifyStatus) PLUS the memory probe's
// type-asserted GuestMemory + the AgentVersion fast-path — the shape the web memAgent seam has.
type memProber struct {
memErr error
ver string
}
func (p *memProber) NetVerifyStatus(context.Context) (NetVerifyStatus, error) {
return NetVerifyStatus{Phase: "none"}, nil
}
func (p *memProber) GuestMemory(context.Context) (GuestMemoryInfo, error) {
return GuestMemoryInfo{AllocatedMB: 8192}, p.memErr
}
func (p *memProber) AgentVersion() string { return p.ver }
// The capability table: v0.90.0 → Yes, v0.89.0 → No via the version fast-path; the probe (no
// version) classifies 404 → No, nil → Yes.
func TestGuestMemory_Capability(t *testing.T) {
t.Run("version >= 0.90 → Yes", func(t *testing.T) {
var sc SupportCache
if got := sc.Supports(context.Background(), &memProber{ver: "0.90.0"}, FeatureGuestMemoryResize); got != SupportYes {
t.Errorf("v0.90.0 = %v, want SupportYes", got)
}
})
t.Run("version < 0.90 → No", func(t *testing.T) {
var sc SupportCache
if got := sc.Supports(context.Background(), &memProber{ver: "0.89.0"}, FeatureGuestMemoryResize); got != SupportNo {
t.Errorf("v0.89.0 = %v, want SupportNo", got)
}
})
t.Run("no version, probe 404 → No", func(t *testing.T) {
var sc SupportCache
p := &memProber{memErr: &StatusError{Path: "/guest/memory", Code: http.StatusNotFound}}
if got := sc.Supports(context.Background(), p, FeatureGuestMemoryResize); got != SupportNo {
t.Errorf("probe 404 = %v, want SupportNo", got)
}
})
t.Run("no version, probe ok → Yes", func(t *testing.T) {
var sc SupportCache
if got := sc.Supports(context.Background(), &memProber{}, FeatureGuestMemoryResize); got != SupportYes {
t.Errorf("probe ok = %v, want SupportYes", got)
}
})
}
// GuestMemory decodes the agent's payload; a 404 (pre-0.90) is the typed *StatusError.
func TestClient_GuestMemory(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/guest/memory", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.WriteHeader(405)
return
}
_, _ = w.Write([]byte(`{"ok":true,"data":{"vmid":9201,"allocated_mb":8192,"usage_mb":3000,"host_total_mb":16384,"min_mb":2048,"max_mb":14336,"floor_mb":3512,"running":true}}`))
})
c := newMemTestClient(t, mux)
info, err := c.GuestMemory(context.Background())
if err != nil {
t.Fatalf("GuestMemory: %v", err)
}
if info.AllocatedMB != 8192 || info.UsageMB != 3000 || info.MaxMB != 14336 || info.FloorMB != 3512 || !info.Running {
t.Errorf("decoded wrong: %+v", info)
}
}
func TestClient_GuestMemory_404(t *testing.T) {
c := newMemTestClient(t, http.NewServeMux()) // no route → 404
_, err := c.GuestMemory(context.Background())
var se *StatusError
if !errors.As(err, &se) || se.Code != http.StatusNotFound {
t.Fatalf("pre-0.90 agent must 404 as *StatusError, got %T: %v", err, err)
}
}
// ResizeMemory: success returns old→new; a 412 refusal surfaces *MemoryRefusedError with the code.
func TestClient_ResizeMemory(t *testing.T) {
t.Run("success", func(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/guest/memory", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"ok":true,"data":{"vmid":9201,"old_mb":8192,"new_mb":12288,"unchanged":false}}`))
})
c := newMemTestClient(t, mux)
res, err := c.ResizeMemory(context.Background(), 12288)
if err != nil {
t.Fatalf("ResizeMemory: %v", err)
}
if res.OldMB != 8192 || res.NewMB != 12288 {
t.Errorf("result = %+v", res)
}
})
t.Run("refusal carries the code", func(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/guest/memory", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusPreconditionFailed)
_, _ = w.Write([]byte(`{"ok":false,"error":"requested 3300 MB is too close to current usage 3000 MB (floor 3512 MB)","data":{"code":"below_usage_floor","usage_mb":3000,"floor_mb":3512,"min_mb":2048,"max_mb":14336}}`))
})
c := newMemTestClient(t, mux)
_, err := c.ResizeMemory(context.Background(), 3300)
var refused *MemoryRefusedError
if !errors.As(err, &refused) {
t.Fatalf("want *MemoryRefusedError, got %T: %v", err, err)
}
if refused.Code != "below_usage_floor" || refused.Bounds.UsageMB != 3000 || refused.Bounds.FloorMB != 3512 {
t.Errorf("refusal = %+v", refused)
}
})
}
func newMemTestClient(t *testing.T, mux *http.ServeMux) *Client {
t.Helper()
srv := httptest.NewTLSServer(mux)
t.Cleanup(srv.Close)
fp := sha256.Sum256(srv.Certificate().Raw)
c, err := New(strings.TrimPrefix(srv.URL, "https://"), "test-token", hex.EncodeToString(fp[:]))
if err != nil {
t.Fatalf("New: %v", err)
}
return c
}