package proxmox import ( "context" "io" "net/http" "strings" "testing" ) // F-LEAK (Campaign 8): the fourth root-fenced exception. Its whole justification is that the band is // enforced rather than assumed, so these tests are about the REFUSALS, not the happy path. // // The band is checked in three independent places on purpose: sudoers matches the vmid literally // (`pct destroy 99000[0-9] --purge`), this method re-checks before exec, and the caller checks journal // provenance. These tests pin the middle one; the sudoers glob is proven live. type recordingRunner struct { calls [][]string err error } 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, r.err } func (r *recordingRunner) RunStdin(_ context.Context, _ io.Reader, name string, args ...string) ([]byte, []byte, error) { r.calls = append(r.calls, append([]string{name}, args...)) return nil, nil, r.err } // A vmid inside the band is destroyed, with --purge so no config/ACL/firewall residue survives. // // RED-PROOF: drop "--purge" from the args → this fails with "destroy is not --purge", and the live // sudoers rule (which matches the FULL vector including --purge) would refuse the call outright. func TestDestroyScratchLXC_InBandDestroysWithPurge(t *testing.T) { r := &recordingRunner{} if err := NewPrivileged(r, "").DestroyScratchLXC(context.Background(), 990003, 990000, 990009); err != nil { t.Fatalf("in-band destroy failed: %v", err) } if len(r.calls) != 1 { t.Fatalf("want exactly 1 exec, got %d: %v", len(r.calls), r.calls) } got := strings.Join(r.calls[0], " ") if got != "pct destroy 990003 --purge" { t.Errorf("exec vector = %q, want %q (it must match the sudoers rule byte for byte)", got, "pct destroy 990003 --purge") } } // THE ONE THAT MATTERS. A vmid outside the band must be refused WITHOUT EXECUTING ANYTHING — a real // customer guest, the golden image, a co-tenant's VM. // // RED-PROOF: remove the `vmid < bandMin || vmid > bandMax` check → this fails with // "REFUSAL FAILED: executed [pct destroy 9201 --purge] for out-of-band vmid 9201". func TestDestroyScratchLXC_RefusesOutOfBandWithoutExecuting(t *testing.T) { for _, vmid := range []int{ 1, // arbitrary 9201, // the LIVE customer guest on both demo boxes 9100, // golden image 9999, // reserved 989999, // one below the band 990010, // one ABOVE the band — the off-by-one } { r := &recordingRunner{} err := NewPrivileged(r, "").DestroyScratchLXC(context.Background(), vmid, 990000, 990009) if err == nil { t.Errorf("vmid %d was NOT refused — the fence is open", vmid) } if len(r.calls) != 0 { t.Errorf("REFUSAL FAILED: executed %v for out-of-band vmid %d", r.calls, vmid) } if err != nil && !strings.Contains(err.Error(), "outside the scratch band") { t.Errorf("vmid %d refused with an unhelpful error: %v", vmid, err) } } } // An unconfigured or inverted band must refuse everything rather than defaulting to something. A zero // band is what a mis-wired caller looks like, and "destroy vmid 0" must never become reachable. // // RED-PROOF: drop the `bandMin <= 0 || bandMax < bandMin` check → the [0,0] case admits vmid 0 and // this fails with "an unconfigured band admitted vmid 0". func TestDestroyScratchLXC_RefusesUnconfiguredBand(t *testing.T) { cases := []struct{ vmid, min, max int }{ {0, 0, 0}, // nothing configured at all {990000, 0, 0}, // band absent, real scratch vmid {990000, 0, 990009}, // min unset {990005, 990009, 990000}, // inverted {990000, -1, 990009}, // negative } for _, c := range cases { r := &recordingRunner{} if err := NewPrivileged(r, "").DestroyScratchLXC(context.Background(), c.vmid, c.min, c.max); err == nil { t.Errorf("band [%d,%d] admitted vmid %d — an unconfigured band must refuse", c.min, c.max, c.vmid) } if len(r.calls) != 0 { t.Errorf("an unconfigured band admitted vmid %d and EXECUTED %v", c.vmid, r.calls) } } } // R-1010: the forced snapshot delete is the API's own flag on the same DELETE (no new root command). func TestDeleteSnapshotForce_R1010(t *testing.T) { var got *http.Request d := &mockDoer{fn: func(r *http.Request) (*http.Response, error) { got = r return jsonResp(200, `{"data":"UPID:demo-felhom:0001:0002:0003:vzdelsnapshot:9201:u@pve!t:"}`), nil }} upid, err := newTestClient(d).DeleteSnapshotForce(context.Background(), 9201, "vzdump") if err != nil || upid == "" { t.Fatalf("DeleteSnapshotForce: upid %q err %v", upid, err) } if got.Method != http.MethodDelete || got.URL.Path != "/api2/json/nodes/demo-felhom/lxc/9201/snapshot/vzdump" || got.URL.Query().Get("force") != "1" { t.Fatalf("request = %s %s, want DELETE …/lxc/9201/snapshot/vzdump?force=1", got.Method, got.URL.String()) } d.fn = func(r *http.Request) (*http.Response, error) { got = r; return jsonResp(200, `{"data":null}`), nil } if _, err := newTestClient(d).DeleteSnapshot(context.Background(), 9201, "vzdump"); err != nil || got.URL.RawQuery != "" { t.Fatalf("the plain delete must carry no force: %s (err %v)", got.URL.String(), err) } }