package localapi import ( "context" "encoding/json" "io" "log/slog" "net/http" "strings" "sync" "testing" "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 { allowed bool reason string calls []string } func (g *fakeGate) AuthorizeWipe(device string) (bool, string) { g.calls = append(g.calls, device) return g.allowed, g.reason } type fakeGuestList struct{ guests []proxmox.Guest } func (f fakeGuestList) ListLXC(context.Context) ([]proxmox.Guest, error) { return f.guests, nil } // 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, Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), }) if err != nil { t.Fatalf("new server: %v", err) } srv.baseCtx = context.Background() 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{allowed: false, 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.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.calls) != 0 { t.Fatal("gate was consulted for a blank-device format (should be benign)") } } // THE HEADLINE 8C TEST: a caller asks to format a DATA-BEARING device. The agent inspects the // device itself, classifies it destructive, the gate refuses pending_signature, and **mkfs is // NEVER called** — the caller's intent cannot wipe data-bearing storage. func TestFormat_DataBearingDevice_RefusedNoMkfs(t *testing.T) { d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true, HasFilesystem: true, FSType: "ext4"}} g := &fakeGate{allowed: false, 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 format: got %d want 403", w.Code) } if got := d.formatted(); len(got) != 0 { t.Fatalf("mkfs WAS called on a data-bearing device — security invariant violated: %v", got) } if len(g.calls) != 1 || g.calls[0] != "/dev/sdb" { t.Fatalf("gate not consulted for the destructive format: %v", g.calls) } if !strings.Contains(w.Body.String(), "operator signature") { t.Fatalf("response did not signal an operator signature is needed: %s", w.Body.String()) } } // Fail-safe: a device the agent could NOT reliably inspect (Probed=false) is treated as // data-bearing → refused, mkfs not called. func TestFormat_AmbiguousProbe_TreatedDestructive(t *testing.T) { d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: false}} // probe failed → DataBearing()=true g := &fakeGate{allowed: false, 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) } } // Even a (hypothetical) gate that ALLOWS does not format a data-bearing device in 8C (the signed // completion is slice 10). func TestFormat_DataBearing_GateAllows_StillNoMkfsIn8C(t *testing.T) { d := &fakeDiskOps{probe: storage.DeviceProbe{Probed: true, HasPartitionTable: true}} g := &fakeGate{allowed: true, reason: "signed"} 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("got %d want 403 (8C never formats data-bearing)", w.Code) } if len(d.formatted()) != 0 { t.Fatal("mkfs called on a data-bearing device even though 8C must refuse") } } 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: target "bulk" is mounted at /mnt/bulk sv := fakeStorage{targets: []hub.StorageTarget{{Name: "bulk", 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) } } // ---- 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, 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 }