package localapi import ( "context" "encoding/json" "fmt" "io" "log/slog" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" "gitea.dooplex.hu/admin/felhom-agent/internal/hub" "gitea.dooplex.hu/admin/felhom-agent/internal/proxmox" ) // ---- fakes ------------------------------------------------------------------------------ // fakeGuests records every VMID it was called with, so a test can assert an op was NOT issued // for a guest the caller is not scoped to. type fakeGuests struct { mu sync.Mutex snapVMIDs []int rbVMIDs []int cfgVMIDs []int mounts map[string]string // mpN -> value, returned by GuestConfig failSnap bool } func (f *fakeGuests) GuestConfig(_ context.Context, vmid int) (proxmox.GuestConfig, error) { f.mu.Lock() f.cfgVMIDs = append(f.cfgVMIDs, vmid) f.mu.Unlock() extra := map[string]json.RawMessage{} for k, v := range f.mounts { b, _ := json.Marshal(v) extra[k] = b } return proxmox.GuestConfig{Extra: extra}, nil } func (f *fakeGuests) Snapshot(_ context.Context, vmid int, _, _ string) (string, error) { f.mu.Lock() f.snapVMIDs = append(f.snapVMIDs, vmid) f.mu.Unlock() if f.failSnap { return "", fmt.Errorf("boom") } return "UPID:snap", nil } func (f *fakeGuests) Rollback(_ context.Context, vmid int, _ string) (string, error) { f.mu.Lock() f.rbVMIDs = append(f.rbVMIDs, vmid) f.mu.Unlock() return "UPID:rb", nil } func (f *fakeGuests) WaitTask(_ context.Context, _ string, _ proxmox.WaitOptions) (proxmox.TaskStatus, error) { return proxmox.TaskStatus{ExitStatus: "OK"}, nil } type fakeBackups struct { mu sync.Mutex vmids []int gate chan struct{} // if non-nil, the backup blocks until it is closed (observe phases) failErr string // if set, the backup returns this error (drives the failed phase) fireSnapshot bool // if true, invoke onSnapshot (simulates snapshot-mode detection, 8B.2) } func (f *fakeBackups) BackupWithSnapshotHook(_ context.Context, vmid int, onSnapshot func()) (hub.Backup, error) { f.mu.Lock() f.vmids = append(f.vmids, vmid) gate := f.gate failErr := f.failErr fire := f.fireSnapshot f.mu.Unlock() if fire && onSnapshot != nil { onSnapshot() // snapshot taken (mid-backup) → phase snapshotted } if gate != nil { <-gate } if failErr != "" { return hub.Backup{VMID: vmid}, fmt.Errorf("%s", failErr) } return hub.Backup{VMID: vmid, Success: true, Archive: "local:backup/vzdump-x", StartedAt: "2026-06-10T00:00:00Z"}, nil } func (f *fakeBackups) called() []int { f.mu.Lock() defer f.mu.Unlock() return append([]int(nil), f.vmids...) } type fakeStore struct { mu sync.Mutex backups []hub.Backup tests []hub.RestoreTest } func (s *fakeStore) RecordBackup(b hub.Backup) { s.mu.Lock() s.backups = append(s.backups, b) s.mu.Unlock() } func (s *fakeStore) Backups(context.Context) []hub.Backup { s.mu.Lock() defer s.mu.Unlock() return append([]hub.Backup(nil), s.backups...) } func (s *fakeStore) RestoreTests(context.Context) []hub.RestoreTest { s.mu.Lock() defer s.mu.Unlock() return append([]hub.RestoreTest(nil), s.tests...) } type fakeStorage struct{ targets []hub.StorageTarget } func (f fakeStorage) Observe(context.Context) ([]hub.StorageTarget, error) { return f.targets, nil } // staticTokens is a fixed token→guest map for server tests (the durable store is tested // separately). Token "A" → guest 8200, token "B" → guest 9300. type staticTokens map[string]int func (m staticTokens) Lookup(tok string) (int, bool) { v, ok := m[tok]; return v, ok } // ---- harness ---------------------------------------------------------------------------- // testNow is the fixed clock the test server uses, so /backup/due cadence math is deterministic. var testNow = time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC) func newTestServerS(t *testing.T, g *fakeGuests, b *fakeBackups, st *fakeStore, sv StorageView) *Server { t.Helper() if sv == nil { sv = fakeStorage{} } srv, err := NewServer(Options{ ListenAddr: "127.0.0.1:0", Guests: g, Backups: b, Store: st, Storage: sv, Tokens: staticTokens{"A": 8200, "B": 9300}, BackupCadence: 24 * time.Hour, Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), }) if err != nil { t.Fatalf("new server: %v", err) } srv.baseCtx = context.Background() srv.now = func() time.Time { return testNow } return srv } func newTestServer(t *testing.T, g *fakeGuests, b *fakeBackups, st *fakeStore, sv StorageView) http.Handler { t.Helper() return newTestServerS(t, g, b, st, sv).Handler() } func do(t *testing.T, h http.Handler, method, path, token, body string) *httptest.ResponseRecorder { t.Helper() var r *http.Request if body != "" { r = httptest.NewRequest(method, path, strings.NewReader(body)) r.Header.Set("Content-Type", "application/json") } else { r = httptest.NewRequest(method, path, nil) } if token != "" { r.Header.Set("Authorization", "Bearer "+token) } w := httptest.NewRecorder() h.ServeHTTP(w, r) return w } // ---- auth ------------------------------------------------------------------------------- func TestAuth_AbsentAndWrongToken(t *testing.T) { g := &fakeGuests{} h := newTestServer(t, g, &fakeBackups{}, &fakeStore{}, nil) if w := do(t, h, "GET", "/storage", "", ""); w.Code != http.StatusUnauthorized { t.Fatalf("absent token: got %d, want 401", w.Code) } if w := do(t, h, "GET", "/storage", "nope", ""); w.Code != http.StatusUnauthorized { t.Fatalf("wrong token: got %d, want 401", w.Code) } if len(g.cfgVMIDs) != 0 { t.Fatal("guest config was read despite failed auth") } } // ---- self-scoping: the headline security test ------------------------------------------- // A token for guest A (8200) cannot snapshot/rollback/backup guest B (9300): the agent refuses // with 403 and the proxmox op is NEVER issued for B. func TestSelfScoping_CrossGuestRefused(t *testing.T) { g := &fakeGuests{} b := &fakeBackups{} h := newTestServer(t, g, b, &fakeStore{}, nil) // token A targets guest B via explicit body vmid → 403, op not called. if w := do(t, h, "POST", "/snapshot", "A", `{"vmid":9300,"snapname":"x"}`); w.Code != http.StatusForbidden { t.Fatalf("cross-guest snapshot: got %d, want 403", w.Code) } if w := do(t, h, "POST", "/rollback", "A", `{"vmid":9300,"snapname":"x"}`); w.Code != http.StatusForbidden { t.Fatalf("cross-guest rollback: got %d, want 403", w.Code) } if w := do(t, h, "POST", "/backup", "A", `{"vmid":9300}`); w.Code != http.StatusForbidden { t.Fatalf("cross-guest backup: got %d, want 403", w.Code) } // token A targets guest B via explicit query vmid → 403. if w := do(t, h, "GET", "/storage?vmid=9300", "A", ""); w.Code != http.StatusForbidden { t.Fatalf("cross-guest storage query: got %d, want 403", w.Code) } if len(g.snapVMIDs) != 0 || len(g.rbVMIDs) != 0 { t.Fatalf("a guest op was issued on a cross-guest request: snaps=%v rb=%v", g.snapVMIDs, g.rbVMIDs) } // the backup goroutine must never have started for B time.Sleep(20 * time.Millisecond) if called := b.called(); len(called) != 0 { t.Fatalf("backup issued on a cross-guest request: %v", called) } } // Own-guest ops call the proxmox op for the CORRECT VMID (the token's guest), even when the // caller names its own guest explicitly. func TestSelfScoping_OwnGuestUsesTokenVMID(t *testing.T) { g := &fakeGuests{} h := newTestServer(t, g, &fakeBackups{}, &fakeStore{}, nil) if w := do(t, h, "POST", "/snapshot", "A", `{"snapname":"pre-deploy"}`); w.Code != http.StatusOK { t.Fatalf("own snapshot: got %d (%s)", w.Code, w.Body.String()) } if w := do(t, h, "POST", "/snapshot", "B", `{"vmid":9300,"snapname":"ok"}`); w.Code != http.StatusOK { t.Fatalf("own snapshot (explicit matching vmid): got %d", w.Code) } if len(g.snapVMIDs) != 2 || g.snapVMIDs[0] != 8200 || g.snapVMIDs[1] != 9300 { t.Fatalf("snapshot used wrong VMIDs: %v", g.snapVMIDs) } } // ---- endpoints -------------------------------------------------------------------------- func TestStorage_ReturnsOnlyThisGuestMountsWithClass(t *testing.T) { g := &fakeGuests{mounts: map[string]string{ "mp0": "fastpool:8,mp=/var/lib/docker,backup=1", "mp1": "bulk:200,mp=/mnt/media,backup=0", }} sv := fakeStorage{targets: []hub.StorageTarget{ {Name: "fastpool", ClassHint: "fast"}, {Name: "bulk", ClassHint: "slow"}, }} h := newTestServer(t, g, &fakeBackups{}, &fakeStore{}, sv) w := do(t, h, "GET", "/storage", "A", "") if w.Code != http.StatusOK { t.Fatalf("storage: got %d (%s)", w.Code, w.Body.String()) } var resp struct { Data StorageResponse `json:"data"` } if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("decode: %v", err) } if resp.Data.VMID != 8200 { t.Fatalf("vmid: got %d want 8200", resp.Data.VMID) } byKey := map[string]MountInfo{} for _, m := range resp.Data.Mounts { byKey[m.Key] = m } if byKey["mp0"].Class != "fast" || byKey["mp0"].Storage != "fastpool" || byKey["mp0"].MountPoint != "/var/lib/docker" || !byKey["mp0"].Backup { t.Fatalf("mp0 wrong: %+v", byKey["mp0"]) } if byKey["mp1"].Class != "slow" || byKey["mp1"].Backup { t.Fatalf("mp1 wrong: %+v", byKey["mp1"]) } if g.cfgVMIDs[0] != 8200 { t.Fatalf("guest config read for wrong vmid: %v", g.cfgVMIDs) } } func TestRollback_RequiresSnapname(t *testing.T) { g := &fakeGuests{} h := newTestServer(t, g, &fakeBackups{}, &fakeStore{}, nil) if w := do(t, h, "POST", "/rollback", "A", `{}`); w.Code != http.StatusBadRequest { t.Fatalf("missing snapname: got %d, want 400", w.Code) } if len(g.rbVMIDs) != 0 { t.Fatal("rollback issued without a snapname") } if w := do(t, h, "POST", "/rollback", "A", `{"snapname":"pre-deploy"}`); w.Code != http.StatusOK { t.Fatalf("valid rollback: got %d", w.Code) } if len(g.rbVMIDs) != 1 || g.rbVMIDs[0] != 8200 { t.Fatalf("rollback VMIDs: %v", g.rbVMIDs) } } func TestSnapshot_RejectsBadName(t *testing.T) { g := &fakeGuests{} h := newTestServer(t, g, &fakeBackups{}, &fakeStore{}, nil) if w := do(t, h, "POST", "/snapshot", "A", `{"snapname":"bad name/slash"}`); w.Code != http.StatusBadRequest { t.Fatalf("bad snapname: got %d, want 400", w.Code) } if len(g.snapVMIDs) != 0 { t.Fatal("snapshot issued with an invalid name") } } func TestBackup_EnqueuesForTokenGuest(t *testing.T) { g := &fakeGuests{} b := &fakeBackups{} st := &fakeStore{} h := newTestServer(t, g, b, st, nil) w := do(t, h, "POST", "/backup", "A", "") if w.Code != http.StatusAccepted { t.Fatalf("backup enqueue: got %d, want 202", w.Code) } // the goroutine should run and record for guest 8200 deadline := time.Now().Add(time.Second) for time.Now().Before(deadline) { if c := b.called(); len(c) == 1 && c[0] == 8200 { break } time.Sleep(5 * time.Millisecond) } if c := b.called(); len(c) != 1 || c[0] != 8200 { t.Fatalf("backup not enqueued for token guest: %v", c) } } func dueOf(t *testing.T, h http.Handler) BackupDueResponse { t.Helper() w := do(t, h, "GET", "/backup/due", "A", "") var resp struct { Data BackupDueResponse `json:"data"` } if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("decode due: %v", err) } return resp.Data } // testNow is 2026-06-10T12:00:00Z, cadence 24h. func TestBackupDue_Cadence(t *testing.T) { st := &fakeStore{} h := newTestServer(t, &fakeGuests{}, &fakeBackups{}, st, nil) // no backup recorded → due if d := dueOf(t, h); !d.Due { t.Fatal("expected due=true with no backup recorded") } // a successful backup 1h ago → NOT due (within the 24h window) st.backups = []hub.Backup{{VMID: 8200, Success: true, StartedAt: "2026-06-10T11:00:00Z"}} if d := dueOf(t, h); d.Due { t.Fatalf("expected due=false 1h after a successful backup; reason=%q", d.Reason) } // a successful backup >24h ago → due again st.backups = []hub.Backup{{VMID: 8200, Success: true, StartedAt: "2026-06-08T00:00:00Z"}} if d := dueOf(t, h); !d.Due { t.Fatal("expected due=true when the newest backup is older than the cadence") } // a FAILED backup 1h ago does NOT satisfy the cadence → still due st.backups = []hub.Backup{{VMID: 8200, Success: false, StartedAt: "2026-06-10T11:00:00Z"}} if d := dueOf(t, h); !d.Due { t.Fatal("expected due=true: a failed backup must not count as a successful one") } } // POST /backup returns a running phase; the job transitions running→done; a successful backup then // flips /backup/due to false (the controller won't re-quiesce in a loop). func TestBackupStatus_RunningToDone(t *testing.T) { st := &fakeStore{} b := &fakeBackups{} srv := newTestServerS(t, &fakeGuests{}, b, st, nil) h := srv.Handler() // gate the backup goroutine so we can observe the running phase deterministically b.gate = make(chan struct{}) w := do(t, h, "POST", "/backup", "A", "") if w.Code != http.StatusAccepted { t.Fatalf("POST /backup: got %d want 202", w.Code) } var br struct { Data BackupResponse `json:"data"` } _ = json.Unmarshal(w.Body.Bytes(), &br) if br.Data.Phase != PhaseRunning || br.Data.JobID == "" { t.Fatalf("expected running phase + job id, got %+v", br.Data) } if ph := statusPhase(t, h); ph != PhaseRunning { t.Fatalf("status while running: got %q want running", ph) } close(b.gate) // let the backup complete // wait for done deadline := time.Now().Add(time.Second) for time.Now().Before(deadline) { if statusPhase(t, h) == PhaseDone { break } time.Sleep(5 * time.Millisecond) } if ph := statusPhase(t, h); ph != PhaseDone { t.Fatalf("status after completion: got %q want done", ph) } } func TestBackupStatus_RunningToFailed(t *testing.T) { b := &fakeBackups{failErr: "vzdump exploded"} srv := newTestServerS(t, &fakeGuests{}, b, &fakeStore{}, nil) h := srv.Handler() if do(t, h, "POST", "/backup", "A", "").Code != http.StatusAccepted { t.Fatal("POST /backup not accepted") } deadline := time.Now().Add(time.Second) for time.Now().Before(deadline) { if statusPhase(t, h) == PhaseFailed { break } time.Sleep(5 * time.Millisecond) } if ph := statusPhase(t, h); ph != PhaseFailed { t.Fatalf("status after a failed backup: got %q want failed", ph) } } // A second POST /backup while one is running does NOT start a second vzdump (single-flight). func TestBackup_SingleFlight(t *testing.T) { b := &fakeBackups{gate: make(chan struct{})} srv := newTestServerS(t, &fakeGuests{}, b, &fakeStore{}, nil) h := srv.Handler() do(t, h, "POST", "/backup", "A", "") do(t, h, "POST", "/backup", "A", "") // should be coalesced onto the running job close(b.gate) time.Sleep(30 * time.Millisecond) if c := b.called(); len(c) != 1 { t.Fatalf("expected a single vzdump for concurrent POSTs, got %d", len(c)) } } func statusPhase(t *testing.T, h http.Handler) string { t.Helper() w := do(t, h, "GET", "/backup/status", "A", "") var resp struct { Data BackupStatusResponse `json:"data"` } _ = json.Unmarshal(w.Body.Bytes(), &resp) return resp.Data.Phase } func waitPhase(t *testing.T, h http.Handler, want string) { t.Helper() deadline := time.Now().Add(time.Second) for time.Now().Before(deadline) { if statusPhase(t, h) == want { return } time.Sleep(5 * time.Millisecond) } t.Fatalf("phase never reached %q (last=%q)", want, statusPhase(t, h)) } // 8B.2: snapshot mode → the job flips to `snapshotted` mid-backup (before done), so the controller // can resume its app early. The gated fake holds the backup open after firing onSnapshot. func TestBackupStatus_Snapshotted_8B2(t *testing.T) { b := &fakeBackups{gate: make(chan struct{}), fireSnapshot: true} srv := newTestServerS(t, &fakeGuests{}, b, &fakeStore{}, nil) h := srv.Handler() if do(t, h, "POST", "/backup", "A", "").Code != http.StatusAccepted { t.Fatal("POST /backup not accepted") } waitPhase(t, h, PhaseSnapshotted) // onSnapshot fired → snapshotted, while the backup is still open close(b.gate) // let the backup complete waitPhase(t, h, PhaseDone) // and it still proceeds to done } // 8B.2 fallback: stop/downgraded mode → onSnapshot never fires → phase never becomes `snapshotted` // (the controller then resumes at done, exactly 8B). func TestBackup_StopMode_NeverSnapshotted_8B2(t *testing.T) { b := &fakeBackups{gate: make(chan struct{}), fireSnapshot: false} srv := newTestServerS(t, &fakeGuests{}, b, &fakeStore{}, nil) h := srv.Handler() do(t, h, "POST", "/backup", "A", "") time.Sleep(40 * time.Millisecond) // give the goroutine time; it must NOT snapshot if ph := statusPhase(t, h); ph != PhaseRunning { t.Fatalf("stop mode: phase %q, want running (snapshotted must not be emitted)", ph) } close(b.gate) waitPhase(t, h, PhaseDone) } func TestBackupStatus_FiltersToThisGuest(t *testing.T) { st := &fakeStore{backups: []hub.Backup{ {VMID: 9300, Success: true, StartedAt: "2026-06-10T10:00:00Z", Archive: "other"}, {VMID: 8200, Success: true, StartedAt: "2026-06-10T09:00:00Z", Archive: "mine-old"}, {VMID: 8200, Success: true, StartedAt: "2026-06-10T11:00:00Z", Archive: "mine-new"}, }} h := newTestServer(t, &fakeGuests{}, &fakeBackups{}, st, nil) w := do(t, h, "GET", "/backup/status", "A", "") if !strings.Contains(w.Body.String(), "mine-new") || strings.Contains(w.Body.String(), "other") { t.Fatalf("status not scoped to this guest / not latest: %s", w.Body.String()) } }