Files
felhom-agent/internal/localapi/disks_test.go
T
admin a2a76e7624 F20-BUG2 + F9-reporting: /disks surfaces wipe_durable_id (gate scheme) + guest_attached
F20-BUG2: the /disks list only carried DurableID in the uuid: scheme (for /disks/assign),
but the wipe gate resolves devices in the byid:/byuuid: scheme — so a customer confirming a
wipe with the advertised id was refused (binding_mismatch). Added a shared s.deviceDurableID
seam used by BOTH handleDisks (new DiskInfo.WipeDurableID) and the format gate, so the id the
customer copies from the list is exactly the id the gate accepts. DurableID (uuid:) is unchanged
(still feeds assign).

F9 (reporting half): added DiskInfo.GuestAttached — whether the drive's namespace is actually
bound into THIS guest's config (guestBoundPaths), distinct from mere host presence (State). This
is the signal whose absence made the HDD look available when it wasn't attached, and resolves the
F2 hdd_configured-vs-/disks disagreement.

Tests: wipe_durable_id is the gate scheme + distinct from uuid:; the list's wipe id matches the
gate's device-id binding (no mismatch); guest_attached true iff bound into the guest.
2026-06-14 15:00:56 +02:00

578 lines
23 KiB
Go

package localapi
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"strings"
"sync"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
"gitea.dooplex.hu/admin/felhom-agent/internal/proxmox"
"gitea.dooplex.hu/admin/felhom-agent/internal/storage"
)
// ---- fakes ------------------------------------------------------------------------------
type fakeDiskOps struct {
mu sync.Mutex
probe storage.DeviceProbe // returned by InspectDevice (Device filled per call)
inspectErr error
formatCalls []string
mountCalls []storage.MountSpec
unmountCalls []string
}
func (f *fakeDiskOps) InspectDevice(_ context.Context, device string) (storage.DeviceProbe, error) {
p := f.probe
p.Device = device
return p, f.inspectErr
}
func (f *fakeDiskOps) Format(_ context.Context, device, _ string) error {
f.mu.Lock()
f.formatCalls = append(f.formatCalls, device)
f.mu.Unlock()
return nil
}
func (f *fakeDiskOps) EnsureMount(_ context.Context, spec storage.MountSpec) error {
f.mu.Lock()
f.mountCalls = append(f.mountCalls, spec)
f.mu.Unlock()
return nil
}
func (f *fakeDiskOps) Unmount(_ context.Context, where string) error {
f.mu.Lock()
f.unmountCalls = append(f.unmountCalls, where)
f.mu.Unlock()
return nil
}
func (f *fakeDiskOps) formatted() []string { f.mu.Lock(); defer f.mu.Unlock(); return append([]string(nil), f.formatCalls...) }
type fakeGate struct {
mu sync.Mutex
decision WipeDecision
reqs []WipeRequest
}
func (g *fakeGate) AuthorizeWipe(req WipeRequest) WipeDecision {
g.mu.Lock()
defer g.mu.Unlock()
g.reqs = append(g.reqs, req)
return g.decision
}
func (g *fakeGate) requests() []WipeRequest {
g.mu.Lock()
defer g.mu.Unlock()
return append([]WipeRequest(nil), g.reqs...)
}
type fakeGuestList struct{ guests []proxmox.Guest }
func (f fakeGuestList) ListLXC(context.Context) ([]proxmox.Guest, error) { return f.guests, nil }
// fakeHostReader is a deterministic HostReader fixture for the role classifier: "/" lives on the
// whole disk /dev/sda, so SystemDisks resolves {/dev/sda} (sysKnown=true) and any storage backed by
// a different whole-disk (e.g. /dev/sdb1) classifies as user-data without touching the real host.
type fakeHostReader struct{ mounts []storage.Mount }
func (f fakeHostReader) Mounts() ([]storage.Mount, error) { return f.mounts, nil }
func (f fakeHostReader) ResolveUUID(string) (string, bool) { return "", false }
func (f fakeHostReader) DeviceExists(string) bool { return true }
func (f fakeHostReader) Rotational(string) (bool, bool) { return false, false }
func (f fakeHostReader) Removable(string) (bool, bool) { return false, false }
// sysOnSDA is the default system-disk fixture (root on /dev/sda) used by the disk-server test helpers.
func sysOnSDA() fakeHostReader {
return fakeHostReader{mounts: []storage.Mount{{Device: "/dev/sda1", MountPoint: "/"}}}
}
// newDiskServer builds a server wired with the 8C disk deps (token A → guest 8200).
func newDiskServer(t *testing.T, d *fakeDiskOps, g *fakeGate, sv StorageView, gl GuestLister) http.Handler {
t.Helper()
if sv == nil {
sv = fakeStorage{}
}
srv, err := NewServer(Options{
ListenAddr: "127.0.0.1:0",
Guests: &fakeGuests{},
Backups: &fakeBackups{},
Store: &fakeStore{},
Storage: sv,
Tokens: staticTokens{"A": 8200, "B": 9300},
Disks: d,
DiskGate: g,
Guests2: gl,
HostReader: sysOnSDA(),
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
})
if err != nil {
t.Fatalf("new server: %v", err)
}
srv.baseCtx = context.Background()
// [AGENT-001] The real anti-retarget re-resolution touches /dev/disk/by-*,
// which doesn't exist in unit tests. Stub it to a successful re-resolve of the
// device the format tests use; antiRetargetResolve itself is covered directly
// in wipe_reresolve_test.go.
srv.reresolveWipe = func(_ context.Context, _ string) (string, error) { return "/dev/sdb", nil }
// F20-BUG2: the wipe id derivation hits /dev/disk/by-* in production; stub it deterministically so
// both the /disks list and the gate (which share this seam) resolve the same id in tests.
srv.deviceDurableID = func(device string) (string, error) { return "byid:wwn-" + strings.TrimPrefix(device, "/dev/"), nil }
return srv.Handler()
}
// ---- the security centerpiece -----------------------------------------------------------
// A blank device (the agent's own probe says blank) is formatted — mkfs called, gate NOT consulted.
func TestFormat_BlankDevice_Formats(t *testing.T) {
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true}} // blank: Probed, nothing set
g := &fakeGate{}
h := newDiskServer(t, d, g, nil, nil)
w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4"}`)
if w.Code != http.StatusOK {
t.Fatalf("blank format: got %d want 200 (%s)", w.Code, w.Body.String())
}
if got := d.formatted(); len(got) != 1 || got[0] != "/dev/sdb" {
t.Fatalf("mkfs not called for blank device: %v", got)
}
if len(g.requests()) != 0 {
t.Fatal("gate was consulted for a blank-device format (should be benign)")
}
}
// THE HEADLINE SECURITY TEST: a caller asks to format a DATA-BEARING device the gate tiers as
// SYSTEM/BACKUP (destructive). The gate refuses pending_signature, **mkfs is NEVER called**, and the
// response surfaces the operator-signature pending op (NOT a customer-confirmation prompt).
func TestFormat_DataBearing_ProtectedRole_RefusedNoMkfs(t *testing.T) {
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true, HasFilesystem: true, FSType: "ext4"}}
g := &fakeGate{decision: WipeDecision{Allowed: false, Tier: "destructive", Reason: "pending_signature"}}
h := newDiskServer(t, d, g, nil, nil)
w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4"}`)
if w.Code != http.StatusForbidden {
t.Fatalf("data-bearing protected format: got %d want 403", w.Code)
}
if got := d.formatted(); len(got) != 0 {
t.Fatalf("mkfs WAS called on a protected data-bearing device — security invariant violated: %v", got)
}
if len(g.requests()) != 1 {
t.Fatalf("gate not consulted for the destructive format: %v", g.requests())
}
if !strings.Contains(w.Body.String(), "operator signature") {
t.Fatalf("response did not signal an operator signature is needed: %s", w.Body.String())
}
}
// A hand-issued confirmed:true on a data-bearing device the gate tiers as SYSTEM/BACKUP is STILL
// refused (operator-signature) and mkfs is NOT called — role beats confirmation. (The gate's
// role-tiering is itself asserted in reconcile/gate_test; here we assert the handler honors a
// destructive verdict even when the caller claimed confirmed:true.)
func TestFormat_DataBearing_ConfirmedTrueButProtected_StillRefused(t *testing.T) {
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true, HasFilesystem: true, FSType: "ext4"}}
g := &fakeGate{decision: WipeDecision{Allowed: false, Tier: "destructive", Reason: "pending_signature"}}
h := newDiskServer(t, d, g, nil, nil)
w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4","confirmed":true,"durable_id":"byid:wwn-xyz"}`)
if w.Code != http.StatusForbidden {
t.Fatalf("confirmed-but-protected format: got %d want 403", w.Code)
}
if got := d.formatted(); len(got) != 0 {
t.Fatalf("mkfs called despite protected role — confirmation must not beat role: %v", got)
}
// The handler must have forwarded the caller's confirmation claim to the gate (the gate, not the
// handler, ignores it for protected roles).
if reqs := g.requests(); len(reqs) != 1 || !reqs[0].Confirmed || reqs[0].ConfirmDurableID != "byid:wwn-xyz" {
t.Fatalf("handler did not forward the confirmation claim to the gate: %+v", reqs)
}
}
// USER-DATA + customer-confirmed (gate Allowed): the wipe proceeds — mkfs IS called, 200.
func TestFormat_DataBearing_UserDataConfirmed_Formats(t *testing.T) {
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true, HasFilesystem: true, FSType: "ext4"}}
g := &fakeGate{decision: WipeDecision{Allowed: true, Tier: "customer_confirmable", Reason: "customer_confirmed"}}
h := newDiskServer(t, d, g, nil, nil)
w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4","confirmed":true,"durable_id":"byuuid:abc"}`)
if w.Code != http.StatusOK {
t.Fatalf("user-data confirmed format: got %d want 200 (%s)", w.Code, w.Body.String())
}
if got := d.formatted(); len(got) != 1 || got[0] != "/dev/sdb" {
t.Fatalf("mkfs not called for a customer-confirmed user-data wipe: %v", got)
}
}
// USER-DATA + NOT yet confirmed (gate refuses pending_confirmation): 403 with needs_confirmation,
// mkfs NOT called, and NO operator-signature pending op (it's the customer's to confirm).
func TestFormat_DataBearing_UserDataNeedsConfirmation(t *testing.T) {
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true, HasFilesystem: true, FSType: "ext4"}}
g := &fakeGate{decision: WipeDecision{Allowed: false, Tier: "customer_confirmable", Reason: "pending_confirmation", NeedsConfirmation: true}}
h := newDiskServer(t, d, g, nil, nil)
w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4"}`)
if w.Code != http.StatusForbidden {
t.Fatalf("user-data unconfirmed: got %d want 403", w.Code)
}
if len(d.formatted()) != 0 {
t.Fatal("mkfs called for an unconfirmed user-data wipe")
}
var resp struct {
Data FormatResponse `json:"data"`
}
_ = json.Unmarshal(w.Body.Bytes(), &resp)
if !resp.Data.NeedsConfirmation {
t.Fatalf("response missing needs_confirmation: %s", w.Body.String())
}
if resp.Data.PendingOp != nil {
t.Fatal("user-data refusal must NOT surface an operator-signature pending op")
}
if strings.Contains(w.Body.String(), "operator signature") {
t.Fatalf("user-data refusal must not ask for an operator signature: %s", w.Body.String())
}
}
// Fail-safe: a device the agent could NOT reliably inspect (Probed=false) is treated as
// data-bearing → routed through the gate, mkfs not called.
func TestFormat_AmbiguousProbe_TreatedDestructive(t *testing.T) {
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: false}} // probe failed → DataBearing()=true
g := &fakeGate{decision: WipeDecision{Allowed: false, Tier: "destructive", Reason: "pending_signature"}}
h := newDiskServer(t, d, g, nil, nil)
w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4"}`)
if w.Code != http.StatusForbidden {
t.Fatalf("ambiguous device: got %d want 403", w.Code)
}
if got := d.formatted(); len(got) != 0 {
t.Fatalf("mkfs called on an unprobed device: %v", got)
}
}
func TestFormat_RejectsBadDeviceOrFSType(t *testing.T) {
d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true}}
h := newDiskServer(t, d, &fakeGate{}, nil, nil)
if w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/../etc","fstype":"ext4"}`); w.Code != http.StatusBadRequest {
t.Fatalf("bad device: got %d want 400", w.Code)
}
if w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ntfs"}`); w.Code != http.StatusBadRequest {
t.Fatalf("bad fstype: got %d want 400", w.Code)
}
if len(d.formatted()) != 0 {
t.Fatal("formatted despite invalid input")
}
}
// ---- assign / eject ---------------------------------------------------------------------
func TestAssign_EnsureMount(t *testing.T) {
d := &fakeDiskOps{}
h := newDiskServer(t, d, &fakeGate{}, nil, nil)
w := do(t, h, "POST", "/disks/assign", "A", `{"uuid":"1234-ABCD","where":"/mnt/data","fstype":"ext4"}`)
if w.Code != http.StatusOK {
t.Fatalf("assign: got %d (%s)", w.Code, w.Body.String())
}
d.mu.Lock()
defer d.mu.Unlock()
if len(d.mountCalls) != 1 || d.mountCalls[0].Where != "/mnt/data" || d.mountCalls[0].UUID != "1234-ABCD" {
t.Fatalf("EnsureMount not called correctly: %+v", d.mountCalls)
}
}
func TestEject_UnmountAndDependents(t *testing.T) {
d := &fakeDiskOps{}
// storage view: USER-DATA target "bulk" (usb on a non-system disk) mounted at /mnt/bulk — the
// role-gate permits ejecting it.
sv := fakeStorage{targets: []hub.StorageTarget{{Name: "bulk", Type: hub.StorageTypeUSB, BackingDevice: "/dev/sdb1", MountPath: "/mnt/bulk"}}}
// guest 8200 mounts storage "bulk"; guest 9300 does not
gl := fakeGuestList{guests: []proxmox.Guest{{VMID: 8200}, {VMID: 9300}}}
h := newDiskServerWithGuestConfigs(t, d, sv, gl, map[int]map[string]string{
8200: {"mp0": "bulk:200,mp=/mnt/media,backup=0"},
9300: {"mp0": "local-lvm:8,mp=/var,backup=1"},
})
w := do(t, h, "POST", "/disks/eject", "A", `{"where":"/mnt/bulk"}`)
if w.Code != http.StatusOK {
t.Fatalf("eject: got %d (%s)", w.Code, w.Body.String())
}
d.mu.Lock()
unmounts := append([]string(nil), d.unmountCalls...)
d.mu.Unlock()
if len(unmounts) != 1 || unmounts[0] != "/mnt/bulk" {
t.Fatalf("Unmount not called: %v", unmounts)
}
var resp struct {
Data struct {
DependentGuests []int `json:"dependent_guests"`
} `json:"data"`
}
_ = json.Unmarshal(w.Body.Bytes(), &resp)
found := false
for _, v := range resp.Data.DependentGuests {
if v == 8200 {
found = true
}
if v == 9300 {
t.Fatal("9300 listed as dependent but it does not mount bulk")
}
}
if !found {
t.Fatalf("dependent guest 8200 not reported: %v", resp.Data.DependentGuests)
}
}
// A2 (security): the eject path is ROLE-GATED at the agent. A system mount (local dir on the OS disk)
// and a backup mount (PBS) are REFUSED 403 with NO Unmount; only a user-data mount ejects. The UI
// hiding the button is not the control — a direct API call to unmount a protected storage is refused.
func TestEject_RoleGated(t *testing.T) {
sv := fakeStorage{targets: []hub.StorageTarget{
{Name: "bulk", Type: hub.StorageTypeUSB, BackingDevice: "/dev/sdb1", MountPath: "/mnt/bulk"}, // user-data
{Name: "local", Type: "local", MountPath: "/var/lib/vz"}, // system (builtin dir)
{Name: "felhom-pbs", Type: hub.StorageTypePBS, MountPath: "/mnt/pbs"}, // backup (PBS)
}}
// system mount → refused, no Unmount.
d := &fakeDiskOps{}
h := newDiskServer(t, d, &fakeGate{}, sv, nil)
if w := do(t, h, "POST", "/disks/eject", "A", `{"where":"/var/lib/vz"}`); w.Code != http.StatusForbidden {
t.Fatalf("eject system mount: got %d want 403 (%s)", w.Code, w.Body.String())
}
d.mu.Lock()
if len(d.unmountCalls) != 0 {
t.Fatalf("Unmount called on a system mount — role-gate bypassed: %v", d.unmountCalls)
}
d.mu.Unlock()
// backup mount (PBS) → refused, no Unmount.
d2 := &fakeDiskOps{}
h2 := newDiskServer(t, d2, &fakeGate{}, sv, nil)
if w := do(t, h2, "POST", "/disks/eject", "A", `{"where":"/mnt/pbs"}`); w.Code != http.StatusForbidden {
t.Fatalf("eject backup mount: got %d want 403 (%s)", w.Code, w.Body.String())
}
d2.mu.Lock()
if len(d2.unmountCalls) != 0 {
t.Fatalf("Unmount called on a backup mount — role-gate bypassed: %v", d2.unmountCalls)
}
d2.mu.Unlock()
// user-data mount → ejects (Unmount called once).
d3 := &fakeDiskOps{}
h3 := newDiskServer(t, d3, &fakeGate{}, sv, nil)
if w := do(t, h3, "POST", "/disks/eject", "A", `{"where":"/mnt/bulk"}`); w.Code != http.StatusOK {
t.Fatalf("eject user-data mount: got %d want 200 (%s)", w.Code, w.Body.String())
}
d3.mu.Lock()
if len(d3.unmountCalls) != 1 || d3.unmountCalls[0] != "/mnt/bulk" {
t.Fatalf("user-data eject did not Unmount /mnt/bulk: %v", d3.unmountCalls)
}
d3.mu.Unlock()
// fail-safe: an unknown mount (no storage target) → refused, no Unmount.
d4 := &fakeDiskOps{}
h4 := newDiskServer(t, d4, &fakeGate{}, sv, nil)
if w := do(t, h4, "POST", "/disks/eject", "A", `{"where":"/mnt/unknown"}`); w.Code != http.StatusForbidden {
t.Fatalf("eject unresolvable mount: got %d want 403 (fail-safe) (%s)", w.Code, w.Body.String())
}
d4.mu.Lock()
if len(d4.unmountCalls) != 0 {
t.Fatalf("Unmount called on an unresolvable mount — fail-safe violated: %v", d4.unmountCalls)
}
d4.mu.Unlock()
}
// ---- guest data-drive passthrough (slice 10 P2) -----------------------------------------
type fakeGuestAttacher struct {
mu sync.Mutex
calls []struct {
vmid int
slot, where string
}
reboots []int
}
func (f *fakeGuestAttacher) AttachBind(_ context.Context, vmid int, mountKey, where string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, struct {
vmid int
slot, where string
}{vmid, mountKey, where})
return nil
}
func (f *fakeGuestAttacher) count() int { f.mu.Lock(); defer f.mu.Unlock(); return len(f.calls) }
func (f *fakeGuestAttacher) RebootGuest(_ context.Context, vmid int) error {
f.mu.Lock()
defer f.mu.Unlock()
f.reboots = append(f.reboots, vmid)
return nil
}
func (f *fakeGuestAttacher) rebootCount() int { f.mu.Lock(); defer f.mu.Unlock(); return len(f.reboots) }
func newAttachServer(t *testing.T, ga GuestAttacher, mounts map[int]map[string]string) http.Handler {
t.Helper()
srv, err := NewServer(Options{
ListenAddr: "127.0.0.1:0", Guests: &fakeGuestsCfg{mounts: mounts}, Backups: &fakeBackups{},
Store: &fakeStore{}, Storage: fakeStorage{}, Tokens: staticTokens{"A": 8200, "B": 9300},
GuestAttach: ga, Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
})
if err != nil {
t.Fatal(err)
}
srv.baseCtx = context.Background()
return srv.Handler()
}
// A first attach picks the lowest free slot (mp0; mp9 bootstrap is taken) and calls the binder.
func TestGuestAttach_PicksFreeSlotAndBinds(t *testing.T) {
ga := &fakeGuestAttacher{}
h := newAttachServer(t, ga, map[int]map[string]string{
8200: {"mp9": "/var/lib/.../bootstrap,mp=/etc/felhom-bootstrap,ro=1"},
})
w := do(t, h, "POST", "/disks/guest-attach", "A", `{"where":"/mnt/felhom-usb"}`)
if w.Code != http.StatusOK {
t.Fatalf("attach: got %d want 200 (%s)", w.Code, w.Body.String())
}
if ga.count() != 1 || ga.calls[0].slot != "mp0" || ga.calls[0].where != "/mnt/felhom-usb" || ga.calls[0].vmid != 8200 {
t.Fatalf("AttachBind not called with mp0/where/vmid: %+v", ga.calls)
}
}
// An already-bound drive is idempotent: returns the existing slot, binder NOT called again.
func TestGuestAttach_Idempotent(t *testing.T) {
ga := &fakeGuestAttacher{}
h := newAttachServer(t, ga, map[int]map[string]string{
8200: {"mp0": "/mnt/felhom-usb/felhom-data,mp=/mnt/felhom-usb"},
})
w := do(t, h, "POST", "/disks/guest-attach", "A", `{"where":"/mnt/felhom-usb"}`)
if w.Code != http.StatusOK {
t.Fatalf("idempotent attach: got %d want 200 (%s)", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), `"already":true`) {
t.Fatalf("expected already:true: %s", w.Body.String())
}
if ga.count() != 0 {
t.Fatalf("AttachBind must NOT be called for an already-bound drive: %+v", ga.calls)
}
}
// A hostile/invalid where is refused with no binder call.
func TestGuestAttach_RejectsBadPath(t *testing.T) {
ga := &fakeGuestAttacher{}
h := newAttachServer(t, ga, map[int]map[string]string{8200: {}})
for _, bad := range []string{`{"where":"/mnt/../etc"}`, `{"where":"/etc/passwd"}`, `{"where":"/mnt/a/b"}`, `{"where":""}`} {
if w := do(t, h, "POST", "/disks/guest-attach", "A", bad); w.Code != http.StatusBadRequest {
t.Fatalf("bad where %s: got %d want 400", bad, w.Code)
}
}
if ga.count() != 0 {
t.Fatal("binder called for an invalid path")
}
}
// Not configured (no GuestAttach dep) → 503.
func TestGuestAttach_NotConfigured(t *testing.T) {
h := newAttachServer(t, nil, map[int]map[string]string{8200: {}})
if w := do(t, h, "POST", "/disks/guest-attach", "A", `{"where":"/mnt/felhom-usb"}`); w.Code != http.StatusServiceUnavailable {
t.Fatalf("unconfigured: got %d want 503", w.Code)
}
}
// Guest reboot (activation) returns 202 and triggers RebootGuest for the token's vmid (detached).
func TestGuestReboot_Accepted(t *testing.T) {
ga := &fakeGuestAttacher{}
h := newAttachServer(t, ga, map[int]map[string]string{8200: {}})
w := do(t, h, "POST", "/guest/reboot", "A", "")
if w.Code != http.StatusAccepted {
t.Fatalf("reboot: got %d want 202 (%s)", w.Code, w.Body.String())
}
// The reboot runs in a goroutine; give it a moment to record the call.
for i := 0; i < 100 && ga.rebootCount() == 0; i++ {
time.Sleep(time.Millisecond)
}
if ga.rebootCount() != 1 || ga.reboots[0] != 8200 {
t.Fatalf("RebootGuest not invoked for vmid 8200: %+v", ga.reboots)
}
}
// A cross-guest reboot (body vmid != token's) is refused 403, no reboot.
func TestGuestReboot_CrossGuest403(t *testing.T) {
ga := &fakeGuestAttacher{}
h := newAttachServer(t, ga, map[int]map[string]string{8200: {}})
if w := do(t, h, "POST", "/guest/reboot", "A", `{"vmid":9300}`); w.Code != http.StatusForbidden {
t.Fatalf("cross-guest reboot: got %d want 403", w.Code)
}
time.Sleep(5 * time.Millisecond)
if ga.rebootCount() != 0 {
t.Fatal("reboot triggered for a cross-guest request")
}
}
// ---- auth / config ----------------------------------------------------------------------
func TestDisks_CrossGuest403(t *testing.T) {
h := newDiskServer(t, &fakeDiskOps{probe: storage.DeviceProbe{Probed: true}}, &fakeGate{}, nil, nil)
if w := do(t, h, "GET", "/disks?vmid=9300", "A", ""); w.Code != http.StatusForbidden {
t.Fatalf("cross-guest /disks: got %d want 403", w.Code)
}
if w := do(t, h, "POST", "/disks/format", "A", `{"vmid":9300,"device":"/dev/sdb","fstype":"ext4"}`); w.Code != http.StatusForbidden {
t.Fatalf("cross-guest format: got %d want 403", w.Code)
}
}
func TestDisks_NotConfigured(t *testing.T) {
// a server with no disk deps → 503 on disk endpoints
srv, err := NewServer(Options{
ListenAddr: "127.0.0.1:0", Guests: &fakeGuests{}, Backups: &fakeBackups{},
Store: &fakeStore{}, Storage: fakeStorage{}, Tokens: staticTokens{"A": 8200},
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
})
if err != nil {
t.Fatal(err)
}
srv.baseCtx = context.Background()
h := srv.Handler()
if w := do(t, h, "POST", "/disks/format", "A", `{"device":"/dev/sdb","fstype":"ext4"}`); w.Code != http.StatusServiceUnavailable {
t.Fatalf("unconfigured format: got %d want 503", w.Code)
}
}
// ---- helpers ----------------------------------------------------------------------------
// newDiskServerWithGuestConfigs is like newDiskServer but with a fakeGuests that returns the given
// per-vmid mount maps (so eject's dependent-scan can resolve).
func newDiskServerWithGuestConfigs(t *testing.T, d *fakeDiskOps, sv StorageView, gl GuestLister, mounts map[int]map[string]string) http.Handler {
t.Helper()
fg := &fakeGuestsCfg{mounts: mounts}
srv, err := NewServer(Options{
ListenAddr: "127.0.0.1:0", Guests: fg, Backups: &fakeBackups{}, Store: &fakeStore{},
Storage: sv, Tokens: staticTokens{"A": 8200, "B": 9300},
Disks: d, DiskGate: &fakeGate{}, Guests2: gl, HostReader: sysOnSDA(),
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
})
if err != nil {
t.Fatal(err)
}
srv.baseCtx = context.Background()
return srv.Handler()
}
// fakeGuestsCfg returns per-vmid mountpoints from GuestConfig.
type fakeGuestsCfg struct{ mounts map[int]map[string]string }
func (f *fakeGuestsCfg) GuestConfig(_ context.Context, vmid int) (proxmox.GuestConfig, error) {
extra := map[string]json.RawMessage{}
for k, v := range f.mounts[vmid] {
b, _ := json.Marshal(v)
extra[k] = b
}
return proxmox.GuestConfig{Extra: extra}, nil
}
func (f *fakeGuestsCfg) Snapshot(context.Context, int, string, string) (string, error) { return "", nil }
func (f *fakeGuestsCfg) Rollback(context.Context, int, string) (string, error) { return "", nil }
func (f *fakeGuestsCfg) WaitTask(context.Context, string, proxmox.WaitOptions) (proxmox.TaskStatus, error) {
return proxmox.TaskStatus{ExitStatus: "OK"}, nil
}