package localapi import ( "context" "encoding/json" "net/http" "sync" "testing" "gitea.dooplex.hu/admin/felhom-agent/internal/proxmox" ) // fakeMemory is a MemoryOps fake: it holds the guest's allocation/usage + host total, counts // SetConfig calls (so refusals can prove the non-effect), and — when applyReflect is set — makes a // SetConfig update the observed maxmem so the post-apply verify read passes. type fakeMemory struct { mu sync.Mutex allocMB int64 usageBytes int64 maxmemBytes int64 hostTotalB int64 status string setCalls int lastParams map[string]string setUPID string setErr error applyReflect bool } func (f *fakeMemory) GuestConfig(_ context.Context, _ int) (proxmox.GuestConfig, error) { f.mu.Lock() defer f.mu.Unlock() return proxmox.GuestConfig{Memory: f.allocMB}, nil } func (f *fakeMemory) GuestStatus(_ context.Context, vmid int) (proxmox.Guest, error) { f.mu.Lock() defer f.mu.Unlock() return proxmox.Guest{VMID: vmid, Mem: f.usageBytes, MaxMem: f.maxmemBytes, Status: f.status}, nil } func (f *fakeMemory) NodeStatus(_ context.Context) (proxmox.NodeStatus, error) { f.mu.Lock() defer f.mu.Unlock() var ns proxmox.NodeStatus ns.Memory.Total = f.hostTotalB return ns, nil } func (f *fakeMemory) SetConfig(_ context.Context, _ int, params map[string]string) (string, error) { f.mu.Lock() defer f.mu.Unlock() f.setCalls++ f.lastParams = params if f.setErr != nil { return "", f.setErr } if f.applyReflect { if mb, ok := params["memory"]; ok { var v int64 for _, c := range mb { v = v*10 + int64(c-'0') } f.allocMB = v f.maxmemBytes = v * mib } } return f.setUPID, nil } func (f *fakeMemory) WaitTask(_ context.Context, _ string, _ proxmox.WaitOptions) (proxmox.TaskStatus, error) { return proxmox.TaskStatus{Status: "stopped", ExitStatus: "OK"}, nil } func (f *fakeMemory) calls() int { f.mu.Lock(); defer f.mu.Unlock(); return f.setCalls } // memServer builds a valid server with the memory fake wired and token "A" → guest 8200. func memServer(t *testing.T, f *fakeMemory) *Server { t.Helper() s := newTestServerS(t, &fakeGuests{}, &fakeBackups{}, &fakeStore{}, nil) s.mem = f return s } // stdFixture: allocated 8192 MB, usage 3000 MB, host_total 16384 MB, running. func stdFixture() *fakeMemory { return &fakeMemory{ allocMB: 8192, usageBytes: 3000 * mib, maxmemBytes: 8192 * mib, hostTotalB: 16384 * mib, status: "running", applyReflect: true, } } type memResp struct { OK bool `json:"ok"` Data struct { VMID int `json:"vmid"` AllocatedMB int64 `json:"allocated_mb"` UsageMB int64 `json:"usage_mb"` HostTotalMB int64 `json:"host_total_mb"` MinMB int64 `json:"min_mb"` MaxMB int64 `json:"max_mb"` FloorMB int64 `json:"floor_mb"` Running bool `json:"running"` Code string `json:"code"` OldMB int64 `json:"old_mb"` NewMB int64 `json:"new_mb"` Unchanged bool `json:"unchanged"` } `json:"data"` Error string `json:"error"` } func decodeMem(t *testing.T, body []byte) memResp { t.Helper() var m memResp if err := json.Unmarshal(body, &m); err != nil { t.Fatalf("decode response %q: %v", body, err) } return m } // Scenario C — GET /guest/memory: every field agent-computed, in MB. func TestGuestMemory_GET(t *testing.T) { f := stdFixture() h := memServer(t, f).Handler() w := do(t, h, "GET", "/guest/memory", "A", "") if w.Code != 200 { t.Fatalf("GET = %d (%s), want 200", w.Code, w.Body.String()) } d := decodeMem(t, w.Body.Bytes()).Data if d.AllocatedMB != 8192 || d.UsageMB != 3000 || d.HostTotalMB != 16384 || d.MinMB != 2048 || d.MaxMB != 14336 || d.FloorMB != 3512 || !d.Running { t.Errorf("GET fields wrong: %+v", d) } } // Scenario A — grow + shrink happy paths: exactly one SetConfig, correct param, success. func TestGuestMemory_GrowAndShrink(t *testing.T) { t.Run("grow", func(t *testing.T) { f := stdFixture() h := memServer(t, f).Handler() w := do(t, h, "POST", "/guest/memory", "A", `{"memory_mb":12288}`) if w.Code != 200 { t.Fatalf("grow = %d (%s), want 200", w.Code, w.Body.String()) } d := decodeMem(t, w.Body.Bytes()).Data if d.OldMB != 8192 || d.NewMB != 12288 { t.Errorf("grow result = %+v", d) } if f.calls() != 1 { t.Errorf("SetConfig calls = %d, want 1", f.calls()) } if f.lastParams["memory"] != "12288" { t.Errorf("SetConfig param = %q, want 12288", f.lastParams["memory"]) } }) t.Run("shrink above floor", func(t *testing.T) { f := stdFixture() // floor = max(2048, 3000+512) = 3512 h := memServer(t, f).Handler() w := do(t, h, "POST", "/guest/memory", "A", `{"memory_mb":4096}`) if w.Code != 200 { t.Fatalf("shrink = %d (%s), want 200 (4096 >= floor 3512)", w.Code, w.Body.String()) } if f.calls() != 1 { t.Errorf("SetConfig calls = %d, want 1", f.calls()) } }) } // Scenario B — the ruled refusals: 412 with the code, and SetConfig NEVER called. func TestGuestMemory_Refusals(t *testing.T) { cases := []struct { name, body, code string }{ {"below_min", `{"memory_mb":1024}`, "below_min"}, {"above_max", `{"memory_mb":15000}`, "above_max"}, {"below_usage_floor", `{"memory_mb":3300}`, "below_usage_floor"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { f := stdFixture() h := memServer(t, f).Handler() w := do(t, h, "POST", "/guest/memory", "A", c.body) if w.Code != http.StatusPreconditionFailed { t.Fatalf("%s = %d (%s), want 412", c.name, w.Code, w.Body.String()) } d := decodeMem(t, w.Body.Bytes()).Data if d.Code != c.code { t.Errorf("code = %q, want %q", d.Code, c.code) } if f.calls() != 0 { t.Errorf("SetConfig called %d times on a refusal — must be 0", f.calls()) } // The refusal carries fresh bounds so the UI re-renders honestly. if d.MinMB != 2048 || d.MaxMB != 14336 || d.FloorMB != 3512 { t.Errorf("refusal bounds wrong: min=%d max=%d floor=%d", d.MinMB, d.MaxMB, d.FloorMB) } if c.code == "below_usage_floor" && d.UsageMB != 3000 { t.Errorf("below_usage_floor usage_mb = %d, want 3000", d.UsageMB) } }) } } // B4 — cross-guest body vmid → 403, SetConfig not called. func TestGuestMemory_CrossGuestRefused(t *testing.T) { f := stdFixture() h := memServer(t, f).Handler() w := do(t, h, "POST", "/guest/memory", "A", `{"vmid":9999,"memory_mb":10000}`) if w.Code != http.StatusForbidden { t.Fatalf("cross-guest = %d, want 403", w.Code) } if f.calls() != 0 { t.Errorf("SetConfig called on a cross-guest refusal (%d)", f.calls()) } } // B5 — bounds are re-read FRESH per request: raising usage between the GET and the POST makes a // previously-valid shrink target fall below the NEW floor (a stale UI can't smuggle an old floor). func TestGuestMemory_FreshBoundsPerRequest(t *testing.T) { f := stdFixture() // usage 3000 → floor 3512 h := memServer(t, f).Handler() // GET sees floor 3512; target 3600 would be a valid shrink under it. if d := decodeMem(t, do(t, h, "GET", "/guest/memory", "A", "").Body.Bytes()).Data; d.FloorMB != 3512 { t.Fatalf("initial floor = %d, want 3512", d.FloorMB) } // Usage climbs to 3600 MB → floor becomes 4112. The POST must read this FRESH. f.mu.Lock() f.usageBytes = 3600 * mib f.mu.Unlock() w := do(t, h, "POST", "/guest/memory", "A", `{"memory_mb":3700}`) if w.Code != http.StatusPreconditionFailed { t.Fatalf("post = %d (%s), want 412 (fresh floor 4112 > 3700)", w.Code, w.Body.String()) } d := decodeMem(t, w.Body.Bytes()).Data if d.Code != "below_usage_floor" || d.FloorMB != 4112 { t.Errorf("fresh-bounds refusal = code %q floor %d, want below_usage_floor / 4112", d.Code, d.FloorMB) } if f.calls() != 0 { t.Errorf("SetConfig called (%d) despite fresh-floor refusal", f.calls()) } } // target == current allocation → success no-op, no SetConfig. func TestGuestMemory_UnchangedNoOp(t *testing.T) { f := stdFixture() h := memServer(t, f).Handler() w := do(t, h, "POST", "/guest/memory", "A", `{"memory_mb":8192}`) if w.Code != 200 { t.Fatalf("no-op = %d (%s), want 200", w.Code, w.Body.String()) } d := decodeMem(t, w.Body.Bytes()).Data if !d.Unchanged || f.calls() != 0 { t.Errorf("no-op should not SetConfig: unchanged=%v calls=%d", d.Unchanged, f.calls()) } } // Verify-after-apply: SetConfig "succeeds" but the maxmem never reflects the target → 502, no false // success (a pending/reboot-required outcome is caught, never claimed as done). func TestGuestMemory_ApplyNotReflected(t *testing.T) { f := stdFixture() f.applyReflect = false // SetConfig returns ok but the guest keeps its old maxmem h := memServer(t, f).Handler() w := do(t, h, "POST", "/guest/memory", "A", `{"memory_mb":10000}`) if w.Code != http.StatusBadGateway { t.Fatalf("unreflected apply = %d (%s), want 502", w.Code, w.Body.String()) } if f.calls() != 1 { t.Errorf("SetConfig calls = %d, want 1 (it was attempted)", f.calls()) } } // Not configured (nil Memory) → 503 on both routes. func TestGuestMemory_NotConfigured(t *testing.T) { s := newTestServerS(t, &fakeGuests{}, &fakeBackups{}, &fakeStore{}, nil) // mem stays nil h := s.Handler() if w := do(t, h, "GET", "/guest/memory", "A", ""); w.Code != http.StatusServiceUnavailable { t.Errorf("GET nil-mem = %d, want 503", w.Code) } if w := do(t, h, "POST", "/guest/memory", "A", `{"memory_mb":9000}`); w.Code != http.StatusServiceUnavailable { t.Errorf("POST nil-mem = %d, want 503", w.Code) } }