package localapi import ( "context" "encoding/json" "errors" "io" "log/slog" "strings" "testing" "gitea.dooplex.hu/admin/felhom-agent/internal/proxmox" ) // A1 (audit AUDIT-blast-radius-hostroot-localapi-2026-07-02, spike SPIKE-a1-pool-membership-read): // these tests drive the REAL staleLockController (not the Server-level fakeStaleLock) so the // pool-membership intersect in Guests() is the code under test. The staleLockAPI fake simulates a // BROAD token (ListLXC returns non-pool guests too — the exploit precondition); the recording // runner captures every `pct unlock` the controller would execute. // fakeStaleLockAPI is a scripted staleLockAPI: broad-token-shaped ListLXC + a scripted pool read, // with the mutating calls recorded. type fakeStaleLockAPI struct { lxc []proxmox.Guest pool proxmox.PoolInfo poolErr error cfg map[int]proxmox.GuestConfig snaps map[int][]proxmox.Snapshot tasks []proxmox.TaskStatus delsnap []int started []int } func (f *fakeStaleLockAPI) ListLXC(context.Context) ([]proxmox.Guest, error) { return f.lxc, nil } func (f *fakeStaleLockAPI) Pool(_ context.Context, name string) (proxmox.PoolInfo, error) { if f.poolErr != nil { return proxmox.PoolInfo{}, f.poolErr } return f.pool, nil } func (f *fakeStaleLockAPI) GuestConfig(_ context.Context, vmid int) (proxmox.GuestConfig, error) { return f.cfg[vmid], nil } func (f *fakeStaleLockAPI) ListSnapshots(_ context.Context, vmid int) ([]proxmox.Snapshot, error) { return f.snaps[vmid], nil } func (f *fakeStaleLockAPI) ListRunningTasks(context.Context) ([]proxmox.TaskStatus, error) { return f.tasks, nil } func (f *fakeStaleLockAPI) DeleteSnapshot(_ context.Context, vmid int, snapname string) (string, error) { f.delsnap = append(f.delsnap, vmid) return "", nil // synchronous — no WaitTask } func (f *fakeStaleLockAPI) Start(_ context.Context, vmid int) (string, error) { f.started = append(f.started, vmid) return "", nil } func (f *fakeStaleLockAPI) WaitTask(_ context.Context, upid string, _ proxmox.WaitOptions) (proxmox.TaskStatus, error) { return proxmox.TaskStatus{Status: "stopped", ExitStatus: "OK"}, nil } // recordingRunner captures every fenced-CLI invocation (the controller's `pct unlock`). type recordingRunner struct { calls [][]string } func (r *recordingRunner) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) { r.calls = append(r.calls, append([]string{name}, args...)) return nil, nil, nil } func (r *recordingRunner) RunStdin(ctx context.Context, _ io.Reader, name string, args ...string) ([]byte, []byte, error) { return r.Run(ctx, name, args...) } func (r *recordingRunner) unlocked(vmid string) bool { for _, c := range r.calls { if len(c) == 3 && c[0] == "pct" && c[1] == "unlock" && c[2] == vmid { return true } } return false } // staleCfg builds a GuestConfig whose Lock()/OnBoot() read the given values (both live in Extra). func staleCfg(lock string, onboot bool) proxmox.GuestConfig { extra := map[string]json.RawMessage{} if lock != "" { extra["lock"] = json.RawMessage(`"` + lock + `"`) } if onboot { extra["onboot"] = json.RawMessage(`1`) } return proxmox.GuestConfig{Extra: extra} } // poolScanServer wires the REAL production controller (fake API + recording runner) into a Server. func poolScanServer(api *fakeStaleLockAPI, runner *recordingRunner) *Server { ctrl := NewStaleLockController(api, runner, "felhom", nil) return &Server{staleLock: ctrl, logger: slog.New(slog.NewTextHandler(io.Discard, nil))} } // TestStaleLock_ForeignGuestNotReaped forecloses the A1 exploit: under a broad token, a co-tenant // (non-pool) guest with the exact stale-lock signature (snapshot-delete lock + dangling vzdump // snapshot + no vzdump task visible yet) must be EXCLUDED by the pool intersect — never unlocked, // never snapshot-deleted, never started. func TestStaleLock_ForeignGuestNotReaped(t *testing.T) { api := &fakeStaleLockAPI{ lxc: []proxmox.Guest{ // broad-token-shaped: the foreign guest IS enumerated {VMID: 9201, Status: "stopped"}, {VMID: 5000, Status: "stopped"}, }, pool: proxmox.PoolInfo{PoolID: "felhom", Members: []proxmox.PoolMember{{VMID: 9201, Type: "lxc"}}}, cfg: map[int]proxmox.GuestConfig{ 9201: staleCfg("", false), // healthy pool member 5000: staleCfg("snapshot-delete", true), // the co-tenant's interrupted backup }, snaps: map[int][]proxmox.Snapshot{5000: {{Name: "vzdump"}}}, } runner := &recordingRunner{} poolScanServer(api, runner).RecoverStaleLockedGuests(context.Background()) if runner.unlocked("5000") { t.Fatalf("A1 exploit: foreign guest 5000 was unlocked; runner calls=%v", runner.calls) } if contains(api.delsnap, 5000) { t.Fatalf("A1 exploit: foreign guest 5000's vzdump snapshot was deleted; delsnap=%v", api.delsnap) } if contains(api.started, 5000) { t.Fatalf("A1 exploit: foreign guest 5000 was force-started; started=%v", api.started) } } // TestStaleLock_PoolGuestStillReaped is the over-filtering companion: an owned (pool-member) guest // with the same stale signature IS fully recovered — the filter must not be "reap nothing". func TestStaleLock_PoolGuestStillReaped(t *testing.T) { api := &fakeStaleLockAPI{ lxc: []proxmox.Guest{ {VMID: 9201, Status: "stopped"}, {VMID: 5000, Status: "stopped"}, }, pool: proxmox.PoolInfo{PoolID: "felhom", Members: []proxmox.PoolMember{{VMID: 9201, Type: "lxc"}}}, cfg: map[int]proxmox.GuestConfig{ 9201: staleCfg("snapshot-delete", true), // the F2-b recovery case, on the OWNED guest 5000: staleCfg("", false), }, snaps: map[int][]proxmox.Snapshot{9201: {{Name: "vzdump"}}}, } runner := &recordingRunner{} poolScanServer(api, runner).RecoverStaleLockedGuests(context.Background()) if !runner.unlocked("9201") { t.Fatalf("owned guest 9201 must still be unlocked; runner calls=%v", runner.calls) } if !contains(api.delsnap, 9201) { t.Fatalf("owned guest 9201's dangling snapshot must be deleted; delsnap=%v", api.delsnap) } if !contains(api.started, 9201) { t.Fatalf("owned guest 9201 (onboot, stopped) must be started; started=%v", api.started) } } // TestStaleLock_PoolReadFails_SkipsAll: when ownership can't be PROVEN (pool read errors — 403 on a // pre-v1.9.0 ACL, timeout, parse failure), the whole recovery fail-safes: ZERO mutations on ANY // guest, never a fallback to the unfiltered list. func TestStaleLock_PoolReadFails_SkipsAll(t *testing.T) { api := &fakeStaleLockAPI{ lxc: []proxmox.Guest{{VMID: 9201, Status: "stopped"}}, poolErr: errors.New(`proxmox: GET /pools/felhom: 403 Permission check failed (/pool/felhom, Pool.Audit)`), cfg: map[int]proxmox.GuestConfig{9201: staleCfg("snapshot-delete", true)}, snaps: map[int][]proxmox.Snapshot{9201: {{Name: "vzdump"}}}, } runner := &recordingRunner{} poolScanServer(api, runner).RecoverStaleLockedGuests(context.Background()) if len(runner.calls) != 0 || len(api.delsnap) != 0 || len(api.started) != 0 { t.Fatalf("pool-read failure must skip ALL recovery; runner=%v delsnap=%v started=%v", runner.calls, api.delsnap, api.started) } // The wrapped error the :64 guard logs must name the pool read (operator diagnosability). _, err := NewStaleLockController(api, runner, "felhom", nil).Guests(context.Background()) if err == nil || !strings.Contains(err.Error(), "pool membership read (pool=felhom)") { t.Fatalf("Guests() error must name the pool read; got %v", err) } } // TestStaleLockController_GuestsIntersect covers the intersect edges (§8): storage-type pool members // and zero-vmid entries never grant membership; an EMPTY pool is a valid empty scan, not an error. func TestStaleLockController_GuestsIntersect(t *testing.T) { api := &fakeStaleLockAPI{ lxc: []proxmox.Guest{{VMID: 9201}, {VMID: 5000}}, pool: proxmox.PoolInfo{PoolID: "felhom", Members: []proxmox.PoolMember{ {VMID: 9201, Type: "lxc"}, {VMID: 0, Type: "storage"}, // a pool-attached storage — must not grant vmid-0 membership }}, } ctrl := NewStaleLockController(api, &recordingRunner{}, "felhom", nil) got, err := ctrl.Guests(context.Background()) if err != nil { t.Fatalf("Guests: %v", err) } if len(got) != 1 || got[0].VMID != 9201 { t.Fatalf("intersect must keep exactly the guest pool members; got %v", got) } api.pool = proxmox.PoolInfo{PoolID: "felhom"} // empty pool (a box before first provision) got, err = ctrl.Guests(context.Background()) if err != nil { t.Fatalf("empty pool must not error: %v", err) } if len(got) != 0 { t.Fatalf("empty pool ⇒ empty scan; got %v", got) } }